All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 1m36s
Удалены (0 runtime importers в app/, scripts/, packages/; проверено grep'ом, включая intra-legacy импорты в оставшихся 21 legacy-модуле scrapers/): - backend/app/services/scrapers/avito_imv.py - backend/app/services/scrapers/domclick_detail.py - backend/app/services/scrapers/ekb_geoportal_client.py - backend/app/services/scrapers/yandex_detail.py - backend/app/services/scrapers/yandex_valuation.py Тесты — удалены (pure legacy-vs-kit parity, kit-only coverage уже есть в другом месте): - tests/scrapers/test_ekb_geoportal_client_kit_parity.py - tests/scrapers/test_domclick_detail_kit_parity.py Тесты — конвертированы в kit-only (импорт легаси заменён на scraper_kit.*, покрытие сохранено без потерь): - tests/scrapers/test_avito_imv_kit_parity.py (parity-тесты убраны, config-footgun regression на kit-стороне оставлен) - tests/scrapers/test_avito_imv_browser_transport.py - tests/scrapers/test_yandex_valuation_kit_migration.py (parity убрана, footgun- регрессия mandatory config/delay_provider оставлена) - tests/scrapers/test_domclick_detail.py (+ exceptions переведены на scraper_kit.domclick_exceptions, т.к. kit parse_detail_html поднимает их) - tests/test_avito_imv_parse.py - tests/test_ekb_geoportal_ingest.py (client-часть; ingest/geocoder-часть не трогали — не зависят от удалённого модуля) - tests/test_yandex_detail.py - tests/test_yandex_detail_structural.py - tests/test_yandex_valuation.py (YandexValuationScraper() → config=_KIT_CONFIG, kit конструктор требует обязательный config) - tests/test_yandex_valuation_save.py - tests/test_extval_house_id_write_path.py (только yandex_valuation-часть; cian_valuation остался нетронутым — живой легаси-модуль) - tests/test_yandex_history_area_filter.py Тесты — частично отредактированы (убраны только части про удалённые модули, живые легаси-модули/их parity не тронуты): - tests/scrapers/test_admin_domclick_ingest_kit_parity.py (base.py/ScrapedLot parity остался) - tests/scrapers/test_admin_yandex_kit_parity.py (yandex_realty parity остался) - tests/scrapers/test_admin_avito_kit_parity.py (avito.py/avito_houses.py parity остался; _parse_price parity убран) - tests/scrapers/test_avito_unix_date_tz_consistency.py (IMV-ветка переведена на kit avito.imv, легаси avito/avito_houses/avito_shared/avito_detail не тронуты) - tests/tasks/test_yandex_detail_backfill.py (subject — kit-задача, уже вызывает scraper_kit.providers.yandex.detail; save_detail_enrichment coverage-тесты внизу файла переведены на kit) - tests/test_scraper_kit_yandex_golden_parity.py (detail/valuation parity убраны; serp/newbuilding/house_type_normalizer/build_url для yandex_realty/ yandex_newbuilding не тронуты) - tests/test_scraper_proxy.py (YandexValuation proxy-тесты переведены на kit config= DI; test_avito_imv_own_session_receives_proxies удалён — дублирует test_avito_imv_kit_parity.py footgun-тесты) - tests/test_yandex_scrapers_delay_wiring.py (yandex_detail/yandex_valuation → kit delay_provider=; yandex_realty/yandex_newbuilding не тронуты) - tests/test_scraper_kit_group_c_backfill_kit_parity.py (docstring уточнение, без функциональных изменений) Полный backend-suite зелёный (3253 passed, 6 skipped) кроме известного pre-existing flake test_search_api.py::test_search_cache_hit (#2208, не регрессия этого PR). ruff check + ruff format — чисто на всех изменённых файлах.
202 lines
6.5 KiB
Python
202 lines
6.5 KiB
Python
"""Tests for _save_yandex_history_items — house_id linking + new columns.
|
||
|
||
Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг
|
||
scraper_kit-миграции) — `result` типы переведены на kit-эквивалент
|
||
`scraper_kit.providers.yandex.valuation`, тот же тип, что `estimator.py` реально
|
||
принимает в `_save_yandex_history_items`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||
|
||
from datetime import date
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from scraper_kit.providers.yandex.valuation import (
|
||
ValuationHistoryItem,
|
||
ValuationHouseMeta,
|
||
YandexValuationResult,
|
||
)
|
||
|
||
from app.services.estimator import _save_yandex_history_items
|
||
|
||
|
||
def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult:
|
||
return YandexValuationResult(
|
||
address="Россия, Свердловская область, Екатеринбург, улица Куйбышева, 106",
|
||
offer_category="APARTMENT",
|
||
offer_type="SELL",
|
||
page=1,
|
||
source_url=(
|
||
"https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test&page=1"
|
||
),
|
||
house=ValuationHouseMeta(year_built=2005, total_floors=25, has_lift=True),
|
||
history_items=items,
|
||
)
|
||
|
||
|
||
def _two_items() -> list[ValuationHistoryItem]:
|
||
return [
|
||
ValuationHistoryItem(
|
||
area_m2=50.0,
|
||
rooms=2,
|
||
floor=3,
|
||
start_price=9_000_000,
|
||
last_price=9_000_000,
|
||
publish_date=date(2024, 5, 10),
|
||
removed_date=None,
|
||
exposure_days=30,
|
||
status="В продаже",
|
||
),
|
||
ValuationHistoryItem(
|
||
area_m2=55.0,
|
||
rooms=2,
|
||
floor=5,
|
||
start_price=10_000_000,
|
||
last_price=9_500_000,
|
||
publish_date=date(2024, 3, 1),
|
||
removed_date=date(2024, 6, 1),
|
||
exposure_days=92,
|
||
status="Снято",
|
||
),
|
||
]
|
||
|
||
|
||
def test_save_resolves_house_once_per_page():
|
||
"""match_or_create_house must be called exactly once per page (not per item)."""
|
||
db = MagicMock()
|
||
result = _make_result(_two_items())
|
||
with patch(
|
||
"app.services.estimator.match_or_create_house",
|
||
return_value=(12345, 0.9, "fingerprint"),
|
||
) as m:
|
||
saved = _save_yandex_history_items(db, result)
|
||
assert m.call_count == 1, "match_or_create_house must be called once per page"
|
||
assert saved == 2
|
||
|
||
|
||
def test_save_row_contains_house_id_and_confidence():
|
||
"""Each INSERT row dict carries house_id and source_confidence."""
|
||
db = MagicMock()
|
||
result = _make_result(_two_items())
|
||
with patch(
|
||
"app.services.estimator.match_or_create_house",
|
||
return_value=(54321, 0.9, "fingerprint"),
|
||
):
|
||
_save_yandex_history_items(db, result)
|
||
|
||
# db.execute called once with list-of-dicts (executemany, one round-trip)
|
||
assert db.execute.call_count == 1
|
||
rows = db.execute.call_args_list[0].args[1]
|
||
assert isinstance(rows, list) and len(rows) == 2
|
||
for row in rows:
|
||
assert row["house_id"] == 54321
|
||
assert row["confidence"] == pytest.approx(0.9)
|
||
assert row["notes"] == "match_method=fingerprint"
|
||
assert row["total_floors"] == 25
|
||
|
||
|
||
def test_save_row_contains_removed_date():
|
||
"""Item with removed_date set → row dict has removed_date populated."""
|
||
db = MagicMock()
|
||
items = [
|
||
ValuationHistoryItem(
|
||
area_m2=60.0,
|
||
rooms=2,
|
||
floor=4,
|
||
start_price=11_000_000,
|
||
last_price=10_500_000,
|
||
publish_date=date(2024, 1, 15),
|
||
removed_date=date(2024, 5, 20),
|
||
exposure_days=125,
|
||
status="Снято",
|
||
),
|
||
]
|
||
result = _make_result(items)
|
||
with patch(
|
||
"app.services.estimator.match_or_create_house",
|
||
return_value=(99, 1.0, "new"),
|
||
):
|
||
_save_yandex_history_items(db, result)
|
||
|
||
# args[1] is now the list-of-dicts passed to executemany
|
||
rows = db.execute.call_args_list[0].args[1]
|
||
assert rows[0]["removed_date"] == date(2024, 5, 20)
|
||
|
||
|
||
def test_save_row_removed_date_none_when_active():
|
||
"""Active listing (status В продаже) → removed_date=None in row."""
|
||
db = MagicMock()
|
||
items = [
|
||
ValuationHistoryItem(
|
||
area_m2=42.0,
|
||
rooms=1,
|
||
floor=2,
|
||
start_price=7_500_000,
|
||
last_price=7_500_000,
|
||
publish_date=date(2024, 6, 1),
|
||
removed_date=None,
|
||
exposure_days=10,
|
||
status="В продаже",
|
||
),
|
||
]
|
||
result = _make_result(items)
|
||
with patch(
|
||
"app.services.estimator.match_or_create_house",
|
||
return_value=(7, 1.0, "new"),
|
||
):
|
||
_save_yandex_history_items(db, result)
|
||
|
||
rows = db.execute.call_args_list[0].args[1]
|
||
assert rows[0]["removed_date"] is None
|
||
|
||
|
||
def test_save_handles_match_failure_gracefully():
|
||
"""If match_or_create_house raises, save continues with house_id=None, confidence=0."""
|
||
db = MagicMock()
|
||
items = [
|
||
ValuationHistoryItem(
|
||
area_m2=42.0,
|
||
rooms=1,
|
||
floor=2,
|
||
start_price=7_500_000,
|
||
last_price=7_500_000,
|
||
publish_date=date(2024, 6, 1),
|
||
removed_date=None,
|
||
exposure_days=10,
|
||
status="В продаже",
|
||
),
|
||
]
|
||
result = _make_result(items)
|
||
with patch(
|
||
"app.services.estimator.match_or_create_house",
|
||
side_effect=RuntimeError("simulated lookup failure"),
|
||
):
|
||
saved = _save_yandex_history_items(db, result)
|
||
|
||
assert saved == 1
|
||
rows = db.execute.call_args_list[0].args[1]
|
||
assert rows[0]["house_id"] is None
|
||
assert rows[0]["confidence"] == pytest.approx(0.0)
|
||
assert rows[0]["notes"] is None
|
||
|
||
|
||
def test_save_match_called_with_address_and_year():
|
||
"""match_or_create_house receives correct address and year_built from result.house."""
|
||
db = MagicMock()
|
||
result = _make_result(_two_items())
|
||
with patch(
|
||
"app.services.estimator.match_or_create_house",
|
||
return_value=(1, 1.0, "new"),
|
||
) as m:
|
||
_save_yandex_history_items(db, result)
|
||
|
||
kwargs = m.call_args.kwargs
|
||
assert kwargs["address"] == result.address
|
||
assert kwargs["year_built"] == result.house.year_built
|
||
assert kwargs["ext_source"] == "yandex_valuation"
|