Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
599 lines
28 KiB
Python
599 lines
28 KiB
Python
"""Тесты импорта московского сырья `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_target_region == 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_geo_other_region == 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_target_region == 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"
|
||
filt = mod.REGION_FILTERS[("domclick", 77)]
|
||
assert filt(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
|
||
assert not filt(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
|
||
|
||
|
||
# ── регион 50 (Московская область) ───────────────────────────────────────────
|
||
|
||
|
||
def test_cian_subdomain_selects_oblast_regardless_of_okrug(
|
||
captured: list[dict[str, Any]],
|
||
) -> None:
|
||
"""khimki.cian.ru → область при --region 50, даже если адрес без округа."""
|
||
rows = [
|
||
{
|
||
"id": 1,
|
||
"payload": _payload(
|
||
source_id="khimki-1",
|
||
source_url="https://khimki.cian.ru/sale/flat/1/",
|
||
address="городской округ Химки, Олимпийский проспект, 29",
|
||
),
|
||
}
|
||
]
|
||
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
|
||
assert counters_50.selected == 1
|
||
|
||
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
|
||
assert counters_77.selected == 0
|
||
|
||
|
||
def test_cian_www_with_okrug_stays_moscow_only(captured: list[dict[str, Any]]) -> None:
|
||
"""www.cian.ru + префикс округа — Москва (77), не область (50)."""
|
||
rows = [
|
||
{
|
||
"id": 1,
|
||
"payload": _payload(
|
||
source_id="www-1",
|
||
source_url="https://www.cian.ru/sale/flat/330200428/",
|
||
address="ЦАО, р-н Арбат, улица Арбат, 10",
|
||
),
|
||
}
|
||
]
|
||
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10)
|
||
assert counters_77.selected == 1
|
||
|
||
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
|
||
assert counters_50.selected == 0
|
||
|
||
|
||
def test_yandex_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None:
|
||
rows = [
|
||
{
|
||
"id": 1,
|
||
"payload": _yandex(
|
||
source_id="oblast-1",
|
||
address="Россия, Московская область, Химки, Ивановская улица, 5",
|
||
),
|
||
}
|
||
]
|
||
counters_50 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=50, batch_size=10)
|
||
assert counters_50.selected == 1
|
||
|
||
counters_77 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=77, batch_size=10)
|
||
assert counters_77.selected == 0
|
||
|
||
|
||
def test_domclick_region_50_refuses_to_run() -> None:
|
||
"""Корпус ДомКлика собран под Москву — областных карточек в нём нет вовсе."""
|
||
rows = [{"id": 1, "payload": _payload(source="domclick", address="Москва, ул. Тестовая, 1")}]
|
||
with pytest.raises(SystemExit):
|
||
mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10)
|
||
|
||
|
||
def test_writer_gets_city_none_for_oblast(captured: list[dict[str, Any]]) -> None:
|
||
rows = [
|
||
{
|
||
"id": 1,
|
||
"payload": _payload(
|
||
source_id="khimki-1",
|
||
source_url="https://khimki.cian.ru/sale/flat/1/",
|
||
address="городской округ Химки, Олимпийский проспект, 29",
|
||
),
|
||
}
|
||
]
|
||
mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10)
|
||
assert captured[0]["region_code"] == 50
|
||
assert captured[0]["city"] is None
|
||
|
||
|
||
def test_moscow_region_77_numbers_unchanged(captured: list[dict[str, Any]]) -> None:
|
||
"""Анти-регресс: те же фикстуры, тот же region=77 → те же прежние числа."""
|
||
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=10, region=77)
|
||
assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
|
||
assert counters.read == 5
|
||
assert counters.selected == 2
|
||
assert counters.skipped_not_target_region == 1
|
||
assert counters.skipped_artifact == 1
|
||
assert counters.skipped_invalid == 1
|
||
assert captured[0]["region_code"] == 77
|
||
assert captured[0]["city"] == "Москва"
|
||
|
||
|
||
def test_cian_subdomain_ignores_port_and_case() -> None:
|
||
"""Поддомен читается из `hostname`, а не из `netloc`: порт и регистр хоста
|
||
не должны превращать московскую карточку в «не целевой регион», а областную
|
||
— в московскую. Хост не `*.cian.ru` (включая голый `cian.ru` и похожий
|
||
`fake-cian.ru`) остаётся None — консервативно, ложной области не даёт."""
|
||
assert mod.cian_subdomain({"source_url": "https://WWW.CIAN.RU:443/sale/flat/1/"}) == "www"
|
||
assert (
|
||
mod.cian_subdomain({"source_url": "https://khimki.cian.ru:8443/sale/flat/1/"}) == "khimki"
|
||
)
|
||
assert mod.is_cian_oblast_payload({"source_url": "https://khimki.cian.ru:8443/x"}) is True
|
||
assert mod.is_cian_oblast_payload({"source_url": "https://WWW.CIAN.RU:443/x"}) is False
|
||
for bad in ("https://cian.ru/sale/flat/1/", "https://fake-cian.ru/x", "", None, 42):
|
||
assert mod.cian_subdomain({"source_url": bad}) is None
|
||
assert mod.cian_subdomain({}) is None
|