All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m15s
CI / backend-tests (pull_request) Successful in 17m15s
Оба клиента маршрутизации переводят ЛЮБУЮ кривизну ответа в доменную ошибку
(OsrmLocalUnavailableError / OrsUnavailableError), потому что вызывающие ловят
только её и уходят на прямолинейный fallback:
parcels.py:399 и poi_score.py:348.
Конверсия значения была исключением из этого правила:
out.append(float(d)) # osrm_client_local
out.append(float(sec) / 60.0) # ors_client
Голый float() на нечисловом значении поднимает ValueError или TypeError — они
пролетают мимо обработчиков и дают 500 на /analyze вместо деградации к
прямолинейным расстояниям.
В ors_client довод уже был сформулирован ДВУМЯ СТРОКАМИ НИЖЕ: у проверки длины
написано «иначе zip(strict=True) у вызывающего бросит ValueError (не
OrsUnavailableError) → 500. Закрываем как ORS-сбой». Принцип верный, к самой
конверсии его просто не применили.
Тесты: 4 красных на origin/main с настоящими исключениями —
`ValueError: could not convert string to float: 'не число'` и
`TypeError: float() argument must be a string or a real number, not 'list'`.
Проверены оба типа исключений, а не только ValueError.
Контроль отдельно: null в ответе — это «маршрут не построен», законный None, а не
поломка. Зелёный с обеих сторон, иначе правка могла бы превратить легитимный
пропуск в ошибку.
pytest poi_score + ors + osrm + analyze_osrm_distances: 54 passed, rc=0
pytest tests/services: 3074 passed, 14 skipped, rc=0
Прогон повторён после правок pre-commit ruff-format.
209 lines
8.8 KiB
Python
209 lines
8.8 KiB
Python
"""Тесты локального OSRM-клиента (#39 A2) — /table annotations=distance.
|
||
|
||
Сеть НЕ дёргается: подменяем httpx.Client на MockTransport. Покрывает:
|
||
- корректный URL (sources=0, annotations=distance, координаты в path);
|
||
- парсинг distances в МЕТРАХ + дроп self-index (row[0]);
|
||
- None для недостижимого POI;
|
||
- OsrmLocalUnavailableError на 500 / timeout / code!=Ok / битой форме;
|
||
- пустой destinations → [].
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.services.site_finder import osrm_client_local as osrm
|
||
|
||
|
||
def _install_transport(monkeypatch: pytest.MonkeyPatch, handler) -> None:
|
||
"""Подменить httpx.Client в модуле osrm_client_local на MockTransport(handler).
|
||
|
||
Захватываем реальный httpx.Client ДО патча — иначе factory рекурсивно зовёт сама себя.
|
||
"""
|
||
real_client = httpx.Client
|
||
|
||
def client_factory(*_args, **kwargs):
|
||
kwargs.pop("transport", None)
|
||
return real_client(transport=httpx.MockTransport(handler), **kwargs)
|
||
|
||
monkeypatch.setattr(osrm.httpx, "Client", client_factory)
|
||
|
||
|
||
def test_builds_correct_url_and_parses_meters(monkeypatch):
|
||
"""URL: /table/v1/driving/{coords}?sources=0&annotations=distance; distances в метрах."""
|
||
seen: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
seen["path"] = request.url.path
|
||
seen["params"] = dict(request.url.params)
|
||
# distances[0] = origin→[origin, d1, d2] — метры; self = 0.
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 1234.5, 6789.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
|
||
# Дорожные расстояния (метры), self-index сброшен.
|
||
assert out == [1234.5, 6789.0]
|
||
# URL: профиль driving, координаты origin первой (lon,lat) и POI.
|
||
assert seen["path"] == "/table/v1/driving/60.6,56.8;60.61,56.81;60.62,56.82"
|
||
# Параметры sources=0, annotations=distance.
|
||
assert seen["params"]["sources"] == "0"
|
||
assert seen["params"]["annotations"] == "distance"
|
||
|
||
|
||
def test_base_url_override_hits_foot_url(monkeypatch):
|
||
"""base_url override (#39 A3) → URL строится из переданного base + profile=foot."""
|
||
seen: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
seen["host"] = request.url.host
|
||
seen["path"] = request.url.path
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 321.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = osrm.get_road_distances_m(
|
||
60.6,
|
||
56.8,
|
||
[(60.61, 56.81)],
|
||
profile="foot",
|
||
base_url="http://osrm-walk:5000/", # trailing slash → должен быть срезан
|
||
)
|
||
|
||
assert out == [321.0]
|
||
# Хост — foot-сервис, не дефолтный osrm; trailing slash снят (нет //table).
|
||
assert seen["host"] == "osrm-walk"
|
||
assert seen["path"] == "/table/v1/foot/60.6,56.8;60.61,56.81"
|
||
|
||
|
||
def test_base_url_none_uses_default_driving(monkeypatch):
|
||
"""base_url=None (дефолт) → settings.osrm_local_url + profile=driving (старое поведение)."""
|
||
seen: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
seen["path"] = request.url.path
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 100.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
assert out == [100.0]
|
||
# Дефолтный профиль driving, дефолтный base (settings.osrm_local_url).
|
||
assert seen["path"] == "/table/v1/driving/60.6,56.8;60.61,56.81"
|
||
|
||
|
||
def test_unreachable_poi_returns_none(monkeypatch):
|
||
"""OSRM вернул null (нет маршрута) → None в результате (caller оставит straight-line)."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 500.0, None]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
assert out == [500.0, None]
|
||
|
||
|
||
def test_empty_destinations_returns_empty():
|
||
"""Пустой destinations → [] без HTTP-вызова."""
|
||
assert osrm.get_road_distances_m(60.6, 56.8, []) == []
|
||
|
||
|
||
def test_http_500_raises_unavailable(monkeypatch):
|
||
"""HTTP 500 → OsrmLocalUnavailableError (не пробрасываем HTTPStatusError)."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(500, text="boom")
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="HTTP 500"):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
|
||
def test_timeout_raises_unavailable(monkeypatch):
|
||
"""Timeout/сетевой сбой → OsrmLocalUnavailableError."""
|
||
|
||
def handler(_request):
|
||
raise httpx.ConnectTimeout("timed out")
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="request failed"):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
|
||
def test_code_not_ok_raises_unavailable(monkeypatch):
|
||
"""code != Ok (например NoRoute) → OsrmLocalUnavailableError."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"code": "NoRoute", "distances": None})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="code != Ok"):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
|
||
def test_bad_shape_raises_unavailable(monkeypatch):
|
||
"""Ответ без distances → OsrmLocalUnavailableError."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"code": "Ok", "unexpected": True})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81)])
|
||
|
||
|
||
def test_length_mismatch_raises_unavailable(monkeypatch):
|
||
"""Число расстояний != числу destinations → OsrmLocalUnavailableError (рассинхрон)."""
|
||
|
||
def handler(_request):
|
||
# 2 destinations, но distances содержит только self + 1.
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 500.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError, match="!= destinations"):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
|
||
|
||
def test_non_numeric_distance_degrades_instead_of_500(monkeypatch):
|
||
"""#2464: нечисловое значение в distances → доменная ошибка, а не ValueError.
|
||
|
||
Весь файл переводит любую кривизну ответа в OsrmLocalUnavailableError, потому что
|
||
вызывающий (parcels.py:399) ловит ТОЛЬКО её и уходит на прямолинейный fallback.
|
||
Голый `float(d)` был исключением из этого правила: ValueError/TypeError пролетели
|
||
бы мимо обработчика и дали 500 на /analyze вместо деградации.
|
||
"""
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, "не число", 10.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
|
||
|
||
def test_dict_instead_of_distance_also_degrades(monkeypatch):
|
||
"""TypeError (не только ValueError) тоже обязан стать доменной ошибкой."""
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, {"m": 5}, 10.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
|
||
with pytest.raises(osrm.OsrmLocalUnavailableError):
|
||
osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
|
||
|
||
def test_null_distance_is_still_a_legitimate_none(monkeypatch):
|
||
"""Контроль: null — это «маршрут не построен», а не поломка ответа.
|
||
|
||
Зелёный с обеих сторон: правка не должна превращать законный None в ошибку.
|
||
"""
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, None, 10.0]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
|
||
assert out == [None, 10.0]
|