All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m0s
Review round 2 on #2626 (local houses fallback) found two HIGH-severity bugs verified live against prod data: 1. _extract_local_house_token took the LAST digit-like token in the raw address, so "...Педагогическая, д 15, кв 11" resolved house=11 (apartment number) instead of 15 -- confidently returning a stranger's building with confidence='exact', written to geocode_cache. Fixed by stripping the apartment/office/floor/entrance tail (кв/оф/пом/подъезд/этаж -- NOT корп/к, which is part of the house number) before extracting the token. Fixes the exact prod case from the review plus the corpus+apartment combo ("д 26 к 1, кв 41" -> 26к1, not 41). 2. houses is not an EKB-only table (21% of rows with coords are outside the metro, some as far as another city) -- "улица Маяковского, 7" in houses resolves to Серов, not Екатеринбург, and use_local_ekb only gates the user's query text, not the source row. Added an is_within_ekb_bbox_wide check on every candidate row before it can become a match. Also addressed two MEDIUM findings from the same review: 3. The "<номер> -> <номер>к1" corpus guess only checked uniqueness among к1-labelled rows, so real multi-building addresses (Онуфриева 24: к1/к2/к3, 250-400m apart) resolved confidently to к1 anyway. Guess is now skipped when any other corpus/slash variant of the same base number exists among the street's candidates. 4. Houses-fallback results are no longer cached in geocode_cache -- the source (scraped listings) is less reliable than geoportal/cadastral/ Nominatim, and the lookup is cheap/local, so caching only extended the lifetime of a possible bad match. Side benefit: address_refined now survives every repeat request of the same raw address, not just the first. Also added ORDER BY address, id to the underlying query so the coordinate dedup picks a deterministic row (LOW finding #5). 14 new/updated tests in test_geocoder_local_houses_fallback.py cover all five findings against real prod address/houses-row fixtures. Full geocoder + dadata + estimator/pdf regression suite (402 tests) green.
510 lines
23 KiB
Python
510 lines
23 KiB
Python
"""Unit tests for the `houses` fallback tier of `geocode()` (#2626).
|
||
|
||
Covers:
|
||
- `_norm_local_house`: normalization of corpus/slash house-number forms
|
||
(«49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»).
|
||
- `_extract_local_house_token`: pulling the house-number token out of a raw
|
||
user address, WITH the corpus/slash suffix that `_parse_street_house`'s
|
||
`_HOUSE_NUM` drops.
|
||
- `_clean_local_house_street` / `_row_local_house`: extracting a comparable
|
||
(street, house) pair out of the free-text `houses.address` column (multiple
|
||
scraper source formats — avito/cian/derived/yandex_valuation).
|
||
- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical
|
||
name), regardless of leading district/city noise.
|
||
- `_local_houses_match`: full tier with a mocked DB session —
|
||
exact number match, corpus-1 fallback guess («49» → «49к1»), and the
|
||
defensive "ambiguous → None" invariant (no guessing on >1 distinct match).
|
||
- `geocode()` wiring: local-houses tier is the LAST step, only reached when
|
||
cache/geoportal/cadastral/Nominatim all miss, and marks
|
||
`GeocodeResult.address_refined=True`.
|
||
|
||
Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as
|
||
regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1»,
|
||
«ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» →
|
||
genuinely ambiguous in prod data (two DIFFERENT buildings both stored as
|
||
«Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
_wp_mock = MagicMock()
|
||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||
|
||
from app.services.geocoder import ( # noqa: E402
|
||
GeocodeSuggestion,
|
||
_clean_local_house_street,
|
||
_extract_local_house_token,
|
||
_local_houses_match,
|
||
_norm_local_house,
|
||
_row_local_house,
|
||
_street_tail_matches,
|
||
geocode,
|
||
)
|
||
|
||
# ── _norm_local_house ────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("49 к 1", "49к1"),
|
||
("49-к1", "49к1"),
|
||
("49 корпус 1", "49к1"),
|
||
("49 корп. 1", "49к1"),
|
||
("88 / 2", "88/2"),
|
||
("88/2", "88/2"),
|
||
("35А", "35а"),
|
||
("13Б", "13б"),
|
||
("13-б", "13б"),
|
||
("44", "44"),
|
||
],
|
||
)
|
||
def test_norm_local_house(raw: str, expected: str) -> None:
|
||
assert _norm_local_house(raw) == expected
|
||
|
||
|
||
# ── _extract_local_house_token ───────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("address", "expected"),
|
||
[
|
||
("ул Крестинского, д 49", "49"),
|
||
("ул. Хрустальногорская, д. 88/2", "88/2"),
|
||
("ул Онуфриева, д 24", "24"),
|
||
("Крестинского 49к1", "49к1"),
|
||
("8 Марта 204", "204"), # digit-leading street name doesn't confuse it
|
||
("Малышева 30", "30"),
|
||
# #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11»
|
||
# → дом «11», чужое здание). Реальные строки из trade_in_estimates:
|
||
(
|
||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||
"ул Педагогическая, д 15, кв 11",
|
||
"15",
|
||
),
|
||
(
|
||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||
"ул Педагогическая, д 15, кв 48",
|
||
"15",
|
||
),
|
||
# корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома,
|
||
# «кв 41» — уйти:
|
||
(
|
||
"620149, Свердловская обл, г Екатеринбург, Ленинский р-н, "
|
||
"ул Начдива Онуфриева, д 26 к 1, кв 41",
|
||
"26к1",
|
||
),
|
||
# подъезд/этаж — тот же класс бага, что и квартира (последнее число в
|
||
# строке — не дом):
|
||
(
|
||
"Россия, Свердловская область, Екатеринбург, Трамвайный переулок, "
|
||
"2к2, подъезд 1, этаж 25, кв. 205",
|
||
"2к2",
|
||
),
|
||
],
|
||
)
|
||
def test_extract_local_house_token(address: str, expected: str) -> None:
|
||
assert _extract_local_house_token(address) == expected
|
||
|
||
|
||
def test_extract_local_house_token_none_for_garbage() -> None:
|
||
assert _extract_local_house_token("") is None
|
||
assert _extract_local_house_token("Екатеринбург") is None
|
||
|
||
|
||
# ── _clean_local_house_street / _street_tail_matches ────────────────────────
|
||
|
||
|
||
def test_clean_local_house_street_strips_type_regardless_of_position() -> None:
|
||
"""Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются."""
|
||
assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева"
|
||
assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская"
|
||
|
||
|
||
def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None:
|
||
"""Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива
|
||
Онуфриева» (каноничное имя ГАР, как в houses.address)."""
|
||
assert _street_tail_matches("начдива онуфриева", "онуфриева") is True
|
||
|
||
|
||
def test_street_tail_matches_exact_equality() -> None:
|
||
assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True
|
||
|
||
|
||
def test_street_tail_matches_rejects_non_suffix_substring() -> None:
|
||
"""«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово."""
|
||
assert _street_tail_matches("онуфриева южная", "онуфриева") is False
|
||
|
||
|
||
# ── _row_local_house: разбор houses.address разных форматов источников ──────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("row_address", "expected"),
|
||
[
|
||
(
|
||
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
|
||
("р-н чкаловский мкр. ботанический крестинского", "49к1"),
|
||
),
|
||
("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")),
|
||
("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")),
|
||
(
|
||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||
("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"),
|
||
),
|
||
("Крестинского, 44", ("крестинского", "44")),
|
||
# house-then-district order («·» separator, no comma before house) —
|
||
# match-from-start of the LAST comma-segment still finds the leading token.
|
||
("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")),
|
||
],
|
||
)
|
||
def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None:
|
||
assert _row_local_house(row_address) == expected
|
||
|
||
|
||
def test_row_local_house_none_without_house_segment() -> None:
|
||
"""Нет запятой (номер дома не отделён сегментом) → None, не гадаем."""
|
||
assert _row_local_house("Крестинского") is None
|
||
assert _row_local_house("") is None
|
||
|
||
|
||
# ── _local_houses_match: full tier, mocked db ────────────────────────────────
|
||
|
||
|
||
def _make_row(address: str, lat: float, lon: float) -> MagicMock:
|
||
row = MagicMock()
|
||
row.address = address
|
||
row.lat = lat
|
||
row.lon = lon
|
||
return row
|
||
|
||
|
||
def _db_with_rows(rows: list[MagicMock]) -> MagicMock:
|
||
db = MagicMock()
|
||
db.execute.return_value.fetchall.return_value = rows
|
||
return db
|
||
|
||
|
||
def test_local_houses_match_exact_house_number() -> None:
|
||
"""«88/2» точно совпадает с единственной строкой houses — возвращает её координаты."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row("Хрустальногорская ул.,88", 56.79412, 60.498687),
|
||
_make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106),
|
||
]
|
||
)
|
||
|
||
hit = _local_houses_match(db, "хрустальногорская", "88/2")
|
||
|
||
assert hit is not None
|
||
assert isinstance(hit, GeocodeSuggestion)
|
||
assert hit.lat == pytest.approx(56.793218)
|
||
assert hit.lon == pytest.approx(60.497106)
|
||
assert hit.kind == "house"
|
||
|
||
|
||
def test_local_houses_match_street_tail_and_corpus1_guess() -> None:
|
||
"""«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ
|
||
корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка)."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row(
|
||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||
56.802928,
|
||
60.551696,
|
||
),
|
||
]
|
||
)
|
||
|
||
hit = _local_houses_match(db, "онуфриева", "24")
|
||
|
||
assert hit is not None
|
||
assert hit.lat == pytest.approx(56.802928)
|
||
assert hit.lon == pytest.approx(60.551696)
|
||
|
||
|
||
def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None:
|
||
"""#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ
|
||
разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1»
|
||
не угадывает конкретное здание среди known-siblings — честный None, не
|
||
«уверенный» результат с confidence='exact' на случайно выбранном доме."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row(
|
||
"р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||
56.802928,
|
||
60.551696,
|
||
),
|
||
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
|
||
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
|
||
]
|
||
)
|
||
|
||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||
|
||
|
||
def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None:
|
||
"""Sibling-guard ловит не только «кN», но и «/N» вариант того же номера."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row("улица X, 24к1", 56.80, 60.60),
|
||
_make_row("улица X, 24/2", 56.81, 60.61),
|
||
]
|
||
)
|
||
|
||
assert _local_houses_match(db, "x", "24") is None
|
||
|
||
|
||
def test_local_houses_match_no_corpus1_candidate_returns_none() -> None:
|
||
"""Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391),
|
||
_make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283),
|
||
]
|
||
)
|
||
|
||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||
|
||
|
||
def test_local_houses_match_ambiguous_exact_number_returns_none() -> None:
|
||
"""Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами
|
||
(две разные строки houses) — неоднозначность, фолбэк не угадывает, None."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row(
|
||
"р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1",
|
||
56.789895,
|
||
60.632464,
|
||
),
|
||
_make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079),
|
||
]
|
||
)
|
||
|
||
assert _local_houses_match(db, "крестинского", "49к1") is None
|
||
|
||
|
||
def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None:
|
||
"""«49» → «49к1»-кандидатов больше одного (разные координаты) → None."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row("улица X, 49к1", 56.80, 60.60),
|
||
_make_row("улица X, 49к1", 56.81, 60.61),
|
||
]
|
||
)
|
||
|
||
assert _local_houses_match(db, "x", "49") is None
|
||
|
||
|
||
def test_local_houses_match_deduplicates_same_building_different_sources() -> None:
|
||
"""Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными
|
||
координатами — НЕ считается неоднозначностью (дедуп по округлённым coords)."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row("улица X, 49к1", 56.800001, 60.600001),
|
||
_make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source
|
||
]
|
||
)
|
||
|
||
hit = _local_houses_match(db, "x", "49к1")
|
||
|
||
assert hit is not None
|
||
assert hit.lat == pytest.approx(56.800001)
|
||
|
||
|
||
def test_local_houses_match_no_guess_for_non_digit_house() -> None:
|
||
"""Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1
|
||
ДОГАДКА не пробуется (не «35к3к1»), результат None."""
|
||
db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)])
|
||
|
||
assert _local_houses_match(db, "x", "35к3") is None
|
||
|
||
|
||
def test_local_houses_match_returns_none_on_db_error() -> None:
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("connection lost")
|
||
|
||
assert _local_houses_match(db, "онуфриева", "24") is None
|
||
|
||
|
||
# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ───────────────
|
||
|
||
|
||
def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None:
|
||
"""Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 —
|
||
~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только
|
||
ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри
|
||
`_local_houses_match` обязан отбросить такую строку, а не вернуть её как
|
||
confidence='exact' совпадение чужого города."""
|
||
db = _db_with_rows(
|
||
[_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ
|
||
)
|
||
|
||
assert _local_houses_match(db, "маяковского", "7") is None
|
||
|
||
|
||
def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None:
|
||
"""Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT)
|
||
по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома."""
|
||
db = _db_with_rows(
|
||
[_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)],
|
||
)
|
||
|
||
hit = _local_houses_match(db, "маяковского", "8")
|
||
|
||
assert hit is not None
|
||
assert hit.lat == pytest.approx(56.862701)
|
||
|
||
|
||
# ── deterministic ORDER BY (#2626 review R2 #5) ──────────────────────────────
|
||
|
||
|
||
def test_local_houses_match_query_has_deterministic_order_by() -> None:
|
||
"""Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку
|
||
в порядке сканирования — недетерминированно между вызовами. SQL обязан
|
||
сортировать явно."""
|
||
db = _db_with_rows([])
|
||
|
||
_local_houses_match(db, "x", "1")
|
||
|
||
sql_text = str(db.execute.call_args[0][0])
|
||
assert "ORDER BY" in sql_text.upper()
|
||
|
||
|
||
# ── geocode() wiring — last-resort tier, sets address_refined ───────────────
|
||
|
||
|
||
async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None:
|
||
"""Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир
|
||
вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`."""
|
||
db = MagicMock()
|
||
hit = GeocodeSuggestion(
|
||
label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||
full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1",
|
||
lat=56.802928,
|
||
lon=60.551696,
|
||
kind="house",
|
||
)
|
||
|
||
with (
|
||
patch("app.services.geocoder._cache_get", return_value=None),
|
||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||
patch("app.services.geocoder._cache_put") as mock_cache_put,
|
||
patch(
|
||
"app.services.geocoder._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
),
|
||
patch(
|
||
"app.services.geocoder._local_houses_match",
|
||
return_value=hit,
|
||
) as mock_local,
|
||
):
|
||
result = await geocode("ул Онуфриева, д 24", db)
|
||
|
||
assert result is not None
|
||
assert result.lat == pytest.approx(56.802928)
|
||
assert result.confidence == "exact"
|
||
assert result.address_refined is True
|
||
mock_local.assert_called_once()
|
||
# #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник
|
||
# координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует.
|
||
mock_cache_put.assert_not_called()
|
||
|
||
|
||
async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None:
|
||
"""geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` —
|
||
флаг честно относится ТОЛЬКО к houses-фолбэку."""
|
||
db = MagicMock()
|
||
hit = GeocodeSuggestion(
|
||
label="ул. Серова, д. 27, Екатеринбург",
|
||
full_address="ул. Серова, д. 27, Екатеринбург",
|
||
lat=56.81188,
|
||
lon=60.59739,
|
||
kind="house",
|
||
)
|
||
|
||
with (
|
||
patch("app.services.geocoder._cache_get", return_value=None),
|
||
patch("app.services.geocoder._geoportal_house_match", return_value=hit),
|
||
patch("app.services.geocoder._cache_put"),
|
||
patch(
|
||
"app.services.geocoder._local_houses_match",
|
||
) as mock_local,
|
||
):
|
||
result = await geocode("Серова 27", db)
|
||
|
||
assert result is not None
|
||
assert result.address_refined is False
|
||
mock_local.assert_not_called()
|
||
|
||
|
||
async def test_geocode_returns_none_when_local_houses_also_misses() -> None:
|
||
"""Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем)."""
|
||
db = MagicMock()
|
||
|
||
with (
|
||
patch("app.services.geocoder._cache_get", return_value=None),
|
||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||
patch("app.services.geocoder._cache_put"),
|
||
patch(
|
||
"app.services.geocoder._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
),
|
||
patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local,
|
||
):
|
||
result = await geocode("ул Онуфриева, д 24", db)
|
||
|
||
assert result is None
|
||
mock_local.assert_called_once()
|
||
|
||
|
||
async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None:
|
||
"""End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом
|
||
«кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11
|
||
(`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не
|
||
замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token`
|
||
→ SQL-lookup."""
|
||
db = _db_with_rows(
|
||
[
|
||
_make_row("Педагогическая ул.,11", 56.835387, 60.654104),
|
||
_make_row("Педагогическая ул.,15", 56.835284, 60.655829),
|
||
]
|
||
)
|
||
|
||
with (
|
||
patch("app.services.geocoder._cache_get", return_value=None),
|
||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||
patch("app.services.geocoder._cadastral_house_match", return_value=None),
|
||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||
patch("app.services.geocoder._cache_put") as mock_cache_put,
|
||
patch(
|
||
"app.services.geocoder._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
),
|
||
):
|
||
result = await geocode(
|
||
"620078, Свердловская обл, г Екатеринбург, Кировский р-н, "
|
||
"ул Педагогическая, д 15, кв 11",
|
||
db,
|
||
)
|
||
|
||
assert result is not None
|
||
assert result.lat == pytest.approx(56.835284)
|
||
assert result.lon == pytest.approx(60.655829)
|
||
assert result.address_refined is True
|
||
mock_cache_put.assert_not_called()
|