gendesign/backend/tests/test_osrm_client_local.py
bot-backend 05841b6825
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
fix(ptica): нечисловое значение от OSRM/ORS больше не даёт 500 вместо деградации (#2464)
Оба клиента маршрутизации переводят ЛЮБУЮ кривизну ответа в доменную ошибку
(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.
2026-08-19 21:58:30 +05:00

209 lines
8.8 KiB
Python
Raw 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.

"""Тесты локального 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]