gendesign/backend/tests/test_ors_client.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

152 lines
6.1 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.

"""Тесты 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)])