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.
152 lines
6.1 KiB
Python
152 lines
6.1 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)])
|
||
|
||
|
||
def test_non_numeric_duration_degrades_instead_of_500(with_key, monkeypatch):
|
||
"""#2464: нечисловая длительность → OrsUnavailableError, а не ValueError.
|
||
|
||
Довод уже сформулирован в самом файле — у проверки длины ниже написано, что
|
||
ValueError «не OrsUnavailableError → 500». К самой конверсии принцип применён
|
||
не был: вызывающий (poi_score.py:348) ловит только доменную ошибку.
|
||
"""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"durations": [[0.0, "не число"]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
|
||
with pytest.raises(ors_client.OrsUnavailableError):
|
||
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|
||
|
||
|
||
def test_list_instead_of_duration_also_degrades(with_key, monkeypatch):
|
||
"""TypeError тоже обязан стать доменной ошибкой, не только ValueError."""
|
||
|
||
def handler(_request):
|
||
return httpx.Response(200, json={"durations": [[0.0, [300]]]})
|
||
|
||
_install_transport(monkeypatch, handler)
|
||
|
||
with pytest.raises(ors_client.OrsUnavailableError):
|
||
ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)])
|