gendesign/tradein-mvp/backend/tests/test_msk_raw_import.py
bot-backend f6938452d0
All checks were successful
CI / changes (pull_request) Successful in 11s
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 / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 7m21s
feat(msk-import): ДомКлик как четвёртый источник переливки сырья в listings
Сбор ДомКлика по Москве пошёл (PR #3496), но переливать его в listings было
нечем: в SOURCE_VIEWS стояли только Циан, Авито и Яндекс. Вью
msk_raw.domclick_latest в проде уже существует, не хватало двух записей в
словарях и распознавания города.

## Город читается первым компонентом адреса

У Циана города в адресе нет вовсе и Москву выдаёт префикс округа; у Яндекса
регион стоит вторым компонентом после страны. У ДомКлика адрес начинается
прямо с города: «Москва, улица Нижние Мнёвники, 16 к2».

Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно одно
значение — «Москва», областных нет ни одной. Это ожидаемо, сырьё собрано
запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне сборщика.

Поэтому фильтр здесь — вторая линия, а не единственная. Сменится GUID в
сборщике или приедет в ту же таблицу второй город — импорт не потащит его в
Москву молча, а отложит в skipped_not_moscow.

Новая Москва регион не ломает: «Москва, пос. Птичное, …», «Москва, x.
Ильичевка, …» — посёлок стоит ВТОРЫМ компонентом, первым всегда город, что и
соответствует коду 77.

Сравнение идёт по компоненту целиком, а не по префиксу строки: иначе
«Московская область» и «Московский» прошли бы как Москва. Оба случая
закреплены тестом.

## Тесты

Распознавание Москвы, включая две формы Новой Москвы; отбраковка области,
Екатеринбурга, похожего на префикс «Московский», пустой строки и None;
проводка источника в оба словаря. 45 passed, ruff чистый.

Claude-Session: https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs
2026-09-12 15:16:07 +03:00

485 lines
23 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.

"""Тесты импорта московского сырья `msk_raw` в listings.
Изоляция — как у соседей (tests/tasks/test_backfill_house_coords_from_listings.py):
DATABASE_URL проставляется до импорта модуля, в сеть и в прод-БД тест не ходит.
Сессия — заглушка, отдающая заранее заготовленные payload'ы; писатель
(`save_listings`) подменяется monkeypatch'ем, так что проверяется ровно наш отбор
и пересчёт метки, а не поведение кита.
Главные риски задачи, которые тут и караулятся:
* в регион 77 уедет Московская область (адрес без округа);
* артефакт парсера («Екатеринбург (Cian)») растворится в «не Москва»;
* метка кита 'novostroyki' будет скопирована как есть и выкинет 29 000 карточек
из подбора аналогов (гвард estimator'а берёт только NULL/'vtorichka').
"""
from __future__ import annotations
import os
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import msk_raw_import as mod
def _payload(**over: Any) -> dict[str, Any]:
"""Минимальный сериализованный ScrapedLot, как он лежит в msk_raw.payload."""
base: dict[str, Any] = {
"source": "cian",
"source_url": "https://www.cian.ru/sale/flat/330200428/",
"source_id": "330200428",
"address": "ЦАО, р-н Арбат, улица Арбат, 10",
"price_rub": 25_000_000,
"rooms": 2,
"area_m2": 55.0,
"listing_segment": "novostroyki", # метка кита — её и пересчитываем
"raw_payload": {"is_from_developer": False},
}
base.update(over)
return base
# ── отбор по адресу ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("okrug", mod.MOSCOW_OKRUGS)
def test_every_okrug_prefix_is_moscow(okrug: str) -> None:
assert mod.is_moscow_address(f"{okrug}, р-н Хамовники, улица Льва Толстого, 16")
def test_oblast_address_without_okrug_is_not_moscow() -> None:
"""Адрес без округа — Московская область (регион 50), его в импорт не берём."""
assert not mod.is_moscow_address("Химки, микрорайон Новогорск, Ивановская улица, 5")
assert not mod.is_moscow_address("городской округ Мытищи, Олимпийский проспект, 29")
def test_okrug_must_be_a_whole_token() -> None:
"""«ЗАО» в начале улицы — не округ; иначе область просочилась бы в регион 77."""
assert not mod.is_moscow_address("ЗАОзёрная улица, 25")
def test_ekaterinburg_artifact_is_its_own_reason() -> None:
assert mod.is_artifact_address("Екатеринбург (Cian)")
assert not mod.is_moscow_address("Екатеринбург (Cian)")
# ── пересчёт listing_segment ─────────────────────────────────────────────────
def test_segment_recomputed_to_vtorichka_when_developer_false() -> None:
lot = mod.build_lot(_payload())
assert lot.listing_segment == "vtorichka"
def test_segment_recomputed_to_vtorichka_when_flag_absent() -> None:
"""Отсутствие флага — не признак новостройки (у 31 464 карточек его нет вовсе)."""
lot = mod.build_lot(_payload(raw_payload={}))
assert lot.listing_segment == "vtorichka"
assert mod.build_lot(_payload(raw_payload=None)).listing_segment == "vtorichka"
def test_segment_is_novostroyki_only_when_developer_sells() -> None:
lot = mod.build_lot(_payload(raw_payload={"is_from_developer": True}))
assert lot.listing_segment == "novostroyki"
def test_unknown_payload_keys_are_dropped() -> None:
"""В payload может приехать ключ, которого в модели нет — это не должно ронять импорт."""
lot = mod.build_lot(_payload(some_future_field="x"))
assert lot.source_id == "330200428"
# ── прогон целиком ───────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _FakeResult:
return self
def all(self) -> list[dict[str, Any]]:
return self._rows
def scalar(self) -> Any:
"""Проба существования кэш-таблицы: None = таблицы ещё нет."""
return self._rows[0] if self._rows else None
class _FakeSession:
"""Отдаёт строки постранично по тому же keyset-условию, что и настоящая вью."""
def __init__(self, rows: list[dict[str, Any]]) -> None:
self.rows = rows
self.commits = 0
def execute(self, _sql: Any, params: dict[str, Any] | None = None) -> _FakeResult:
if not params or "after" not in params:
return _FakeResult([]) # DDL кэша пред-геокода — строк не отдаёт
after, limit = params["after"], params["limit"]
return _FakeResult([r for r in self.rows if r["id"] > after][:limit])
def commit(self) -> None:
self.commits += 1
def _rows() -> list[dict[str, Any]]:
return [
{"id": 1, "payload": _payload(source_id="1")},
{"id": 2, "payload": _payload(source_id="2", address="Химки, Ивановская улица, 5")},
{"id": 3, "payload": _payload(source_id="3", address="Екатеринбург (Cian)")},
{"id": 4, "payload": _payload(source_id="4", address="СВАО, улица Милашенкова, 7")},
{"id": 5, "payload": _payload(source_id="5", price_rub=0)}, # невалидный: цена > 0
]
@pytest.fixture()
def captured(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
def _fake_save(db: Any, lots: list[Any], **kwargs: Any) -> tuple[int, int]:
calls.append({"lots": lots, **kwargs})
return len(lots), 0
monkeypatch.setattr(mod, "save_listings", _fake_save)
return calls
def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> None:
db = _FakeSession(_rows())
counters = mod.import_msk_raw(db, batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5
assert counters.selected == 2
assert counters.skipped_not_moscow == 1
assert counters.skipped_artifact == 1
assert counters.skipped_invalid == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_writer_gets_region_77_and_city(captured: list[dict[str, Any]]) -> None:
mod.import_msk_raw(_FakeSession(_rows()), batch_size=10)
assert captured[0]["region_code"] == 77
assert captured[0]["city"] == "Москва"
assert all(lot.listing_segment == "vtorichka" for lot in captured[0]["lots"])
def test_commit_per_batch(captured: list[dict[str, Any]]) -> None:
"""Обрыв на середине не должен терять уже перелитые батчи."""
db = _FakeSession(_rows())
mod.import_msk_raw(db, batch_size=1)
assert db.commits == 2 # ровно столько батчей содержали московские строки
def test_dry_run_writes_nothing(captured: list[dict[str, Any]]) -> None:
db = _FakeSession(_rows())
counters = mod.import_msk_raw(db, batch_size=10, dry_run=True)
assert captured == []
assert db.commits == 0
assert counters.selected == 2 and counters.written == 0
def test_limit_caps_rows_read(captured: list[dict[str, Any]]) -> None:
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=2, limit=3)
assert counters.read == 3
def test_avito_refuses_to_write_without_explicit_opt_in(captured: list[dict[str, Any]]) -> None:
"""У Авито в адресе нет города, а координат нет ни у одной карточки — молча
залить область в регион 77 нельзя."""
rows = [{"id": 1, "payload": _payload(source="avito", address="Варшавское ш.,62к1")}]
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10)
counters = mod.import_msk_raw(
_FakeSession(rows), source="avito", batch_size=10, allow_unfiltered=True
)
assert counters.selected == 1 # префиксный фильтр к Авито не применяется
# ── пред-геокод Авито ────────────────────────────────────────────────────────
#
# Риски этой ветки, которые тут и караулятся:
# * регион возьмут из слага, а не из ответа — Новая Москва (Троицк/Щербинка/
# Коммунарка) уедет в область, хотя это регион 77;
# * область (регион 50) просочится в listings;
# * слаг выпадет из ключа кэша — адреса-тёзки из разных муниципалитетов
# («Центральная ул.,5») схлопнутся в один дом;
# * повторный прогон заново потратит квоту по уже разрешённым адресам.
def _avito(**over: Any) -> dict[str, Any]:
base = _payload(
source="avito",
source_url="https://www.avito.ru/moskva/kvartiry/2-k._kvartira_56_m_56_et._46317006",
address="Варшавское ш.,62к1",
)
base.update(over)
return base
class _Sugg:
"""Минимальный DadataSuggestion — нужны только поля, которые читает отбор."""
def __init__(self, kind: str, lat: float | None, lon: float | None, kladr: str | None) -> None:
self.kind, self.lat, self.lon, self.kladr_id = kind, lat, lon, kladr
self.unrestricted_value = "г Москва, ш Варшавское, д 62 к 1"
def test_slug_comes_from_source_url() -> None:
assert mod.avito_city_slug(_avito()) == "moskva"
himki = _avito(source_url="https://www.avito.ru/himki/kvartiry/1")
assert mod.avito_city_slug(himki) == "himki"
assert mod.avito_city_slug(_avito(source_url="")) is None
assert mod.avito_city_slug({}) is None
def test_cache_key_separates_same_address_in_different_towns() -> None:
"""1110 адресов из 21 570 живут под несколькими слагами — это разные дома."""
assert mod.geo_cache_key("Центральная ул.,5", "moskva") != mod.geo_cache_key(
"Центральная ул.,5", "himki"
)
# нормализация адреса общая с геокодером — регистр и пробелы ключ не множат
assert mod.geo_cache_key(" Варшавское Ш.,62к1 ", "moskva") == mod.geo_cache_key(
"варшавское ш.,62к1", "moskva"
)
def test_moscow_slug_narrows_constraint_others_keep_both_regions() -> None:
"""Не-московский слаг может оказаться Новой Москвой — оба региона в констрейнте."""
assert mod._dadata_regions_for("moskva") == [mod.DADATA_MOSCOW_REGION]
assert mod._dadata_regions_for("moskovskaya_oblast_troitsk") == [
mod.DADATA_MOSCOW_REGION,
mod.DADATA_OBLAST_REGION,
]
assert mod._dadata_regions_for(None) == [
mod.DADATA_MOSCOW_REGION,
mod.DADATA_OBLAST_REGION,
]
def test_region_is_read_from_the_answer_not_from_the_slug() -> None:
"""КЛАДР ответа 77… → регион 77, даже если слаг был подмосковным."""
point = mod._point_from_suggestions([_Sugg("house", 55.6, 37.6, "77000000000000000")])
assert point is not None and point.region_code == 77
oblast = mod._point_from_suggestions([_Sugg("house", 55.9, 37.4, "50000000000000000")])
assert oblast is not None and oblast.region_code == 50
def test_only_house_level_answers_become_points() -> None:
"""Улица/город дают точку в середине улицы — для radius-подбора это хуже, чем ничего."""
assert mod._point_from_suggestions([_Sugg("street", 55.6, 37.6, "77000000000000000")]) is None
assert mod._point_from_suggestions([_Sugg("house", None, None, "77000000000000000")]) is None
# чужой регион (констрейнт пробит) — тоже не берём
assert mod._point_from_suggestions([_Sugg("house", 56.8, 60.6, "66000000000000000")]) is None
def test_budget_stops_asking_instead_of_failing() -> None:
budget = mod.GeocodeBudget(remaining=2)
assert budget.take(5) == 2 and budget.exhausted and budget.spent == 2
assert budget.take(1) == 0 # квота кончилась — просто больше не спрашиваем
def _geo_rows() -> list[dict[str, Any]]:
return [
{"id": 1, "payload": _avito(source_id="1")}, # Москва
{
"id": 2,
"payload": _avito(
source_id="2",
source_url="https://www.avito.ru/himki/kvartiry/2",
address="Ивановская ул.,5",
),
}, # область
{
"id": 3,
"payload": _avito(source_id="3", address="Мкр-н имени В.Н. Махалина, 33"),
}, # не разрешился
]
@pytest.fixture()
def geocoded(monkeypatch: pytest.MonkeyPatch) -> list[list[dict[str, Any]]]:
"""Подменяет резолвер целиком — в сеть и в кэш-таблицу тест не ходит."""
seen: list[list[dict[str, Any]]] = []
def _fake_resolve(db: Any, rows: list[Any], budget: Any, **kwargs: Any) -> dict[str, Any]:
seen.append(list(rows))
return {
mod.geo_cache_key("Варшавское ш.,62к1", "moskva"): mod.GeoPoint(
55.6412, 37.6089, 77, "г Москва, ш Варшавское, д 62 к 1"
),
mod.geo_cache_key("Ивановская ул.,5", "himki"): mod.GeoPoint(
55.8970, 37.4297, 50, "Московская обл, г Химки, ул Ивановская, д 5"
),
}
monkeypatch.setattr(mod, "_resolve_points", _fake_resolve)
return seen
def test_geocode_lets_avito_through_without_allow_unfiltered(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
"""Пред-геокод — штатный путь: аварийный флаг больше не нужен."""
counters = mod.import_msk_raw(
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_oblast == 1
assert counters.skipped_ungeocoded == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_coordinates_reach_the_writer(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
"""Ради geom всё и затевалось — карточка не должна лечь без координат."""
mod.import_msk_raw(_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True)
lot = captured[0]["lots"][0]
assert (lot.lat, lot.lon) == (55.6412, 37.6089)
assert lot.geo_precision == "house"
assert captured[0]["region_code"] == 77
def test_oblast_is_never_written_as_region_77(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
mod.import_msk_raw(_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True)
written = [lot.source_id for call in captured for lot in call["lots"]]
assert "2" not in written, "химкинская карточка не должна уехать в регион 77"
def test_avito_without_geocode_demands_opt_in(captured: list[dict[str, Any]]) -> None:
rows = [{"id": 1, "payload": _avito()}]
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10)
def test_geocode_is_refused_for_cian(captured: list[dict[str, Any]]) -> None:
"""У Циана город виден по префиксу округа даром — квоту на него не тратим."""
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(_rows()), source="cian", batch_size=10, geocode=True)
def test_dry_run_survives_missing_geocode_cache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Первый `--dry-run` идёт по проду, где кэш-таблицы ещё нет.
Таблицу создаёт только боевой прогон, поэтому SELECT по ней в сухом ронял
ровно ту репетицию, ради которой сухой прогон и существует (замер на проде
11.09: UndefinedTable msk_raw.avito_geocode). Резолвер обязан молча считать
кэш пустым и идти к DaData за остатком.
"""
class _NoCacheSession:
def __init__(self) -> None:
self.cache_reads = 0
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _FakeResult:
if "to_regclass" in str(sql):
return _FakeResult([]) # таблицы нет
self.cache_reads += 1
raise AssertionError("к отсутствующей кэш-таблице ходить нельзя")
point = mod.GeoPoint(55.6412, 37.6089, 77, "г Москва, ш Варшавское, д 62 к 1")
async def _fake_geocode(misses: list[Any]) -> dict[str, Any]:
return {misses[0][0]: point}
monkeypatch.setattr(mod, "_geocode_many", _fake_geocode)
db = _NoCacheSession()
points = mod._resolve_points(
db, [{"id": 1, "payload": _avito()}], mod.GeocodeBudget(10), dry_run=True
)
assert db.cache_reads == 0
assert list(points.values()) == [point]
# ── Яндекс: город из второго компонента адреса ───────────────────────────────
def _yandex(**over: Any) -> dict[str, Any]:
base = _payload(
source="yandex",
source_url="https://realty.yandex.ru/offer/1234567890123456789/",
source_id="1234567890123456789",
address="Россия, Москва, Коробейников переулок, 1",
lat=55.737312,
lon=37.601006,
)
base.update(over)
return base
def test_yandex_moscow_is_recognised_without_geocode() -> None:
assert mod.is_moscow_yandex_address("Россия, Москва, Коробейников переулок, 1")
assert mod.is_moscow_yandex_address("Россия, Москва, Зеленоград, корпус 1130")
def test_yandex_oblast_is_not_moscow() -> None:
"""Ровно те два значения, что встречаются в корпусе; область писать нельзя."""
oblast = "Россия, Московская область, Химки, Ивановская улица, 5"
assert not mod.is_moscow_yandex_address(oblast)
assert not mod.is_moscow_yandex_address("Москва, Коробейников переулок, 1") # без страны
assert not mod.is_moscow_yandex_address("")
assert not mod.is_moscow_yandex_address(None)
def test_yandex_oblast_rows_do_not_reach_the_writer(captured: list[dict[str, Any]]) -> None:
rows = [
{"id": 1, "payload": _yandex(source_id="1")},
{
"id": 2,
"payload": _yandex(
source_id="2", address="Россия, Московская область, Химки, Ивановская улица, 5"
),
},
]
counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_not_moscow == 1
def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None:
"""Координаты у Яндекса свои — геокод ему не нужен, geom появляется сразу."""
rows = [{"id": 1, "payload": _yandex()}]
mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
lot = captured[0]["lots"][0]
assert (lot.lat, lot.lon) == (55.737312, 37.601006)
def test_geocode_is_refused_for_yandex(captured: list[dict[str, Any]]) -> None:
"""Квота нужна только Авито: у Яндекса город и точка есть даром."""
with pytest.raises(SystemExit):
mod.import_msk_raw(
_FakeSession([{"id": 1, "payload": _yandex()}]), source="yandex", geocode=True
)
def test_domclick_moscow_is_recognised_without_geocode() -> None:
"""Регион ДомКлика — первый компонент адреса; Новая Москва тоже Москва."""
assert mod.is_moscow_domclick_address("Москва, улица Нижние Мнёвники, 16 к2")
assert mod.is_moscow_domclick_address("Москва, пос. Птичное, Лесная улица, 82")
assert mod.is_moscow_domclick_address("Москва, x. Ильичевка, хутор Ильичёвка, 1")
def test_domclick_non_moscow_is_rejected() -> None:
"""Вторая линия обороны: GUID в сборщике сменится — импорт не смолчит."""
assert not mod.is_moscow_domclick_address("Московская область, Химки, 9 Мая, 21к3")
assert not mod.is_moscow_domclick_address("Екатеринбург, улица Ясная, 2")
# «Москва» должна быть отдельным компонентом, а не префиксом другого слова.
assert not mod.is_moscow_domclick_address("Московский, 1-я улица, 5")
assert not mod.is_moscow_domclick_address("")
assert not mod.is_moscow_domclick_address(None)
def test_domclick_view_is_wired_into_source_views() -> None:
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest"
assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address