All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 4m30s
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
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
has_panorama разбирался, писался и обещался публичным контрактом market.v_houses, но положительных значений не имел ни одного: 0 true из 1536 страниц yandex_valuation за 24.05–12.08.2026 (false 1536, ключ отсутствует 0), houses — 12 непустых из 9468, все false. Соседний has_lift выглядел контрольной группой («текст читается, разбор работает»), но контролирует он только факт отрисовки страницы: "Лифт" in body_text ловит подстроку внутри отрицания, и на живой пятиэтажке 1977 г. со строкой «Лифт отсутствует» парсер отдаёт True. На проде 300 из 313 пятиэтажек «с лифтом». Гейт отрицания для has_lift — отдельной правкой, здесь не смешиваю. Живая проверка боевым трактом 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig + get_scraper_delay — ровно как estimator.py; только чтение, в БД не писал): три адреса Екатеринбурга, все HTTP 200, полный HTML ~1.19 МБ каждый. Вхождений «анорам» без учёта регистра — 0/0/0, равно как panorama / 3D-тур / Виртуальн / Street. Мета при этом разобралась целиком (1974/9эт/панель/2,50/46 объектов; 2020/18эт; 1977/5эт/кирпич/184 объекта). Метка не «переехала» в атрибут или JSON-стейт — её нет в документе вовсе. Это третий вид нуля: не «уже починено» и не «заблокировано выше по потоку», а НЕПРИМЕНИМО к предметной области. Ноль был механически гарантирован самим кодом — поиск подстроки по тексту, где такого слова не бывает, — и о доме не говорил ничего. Цена: снос ломает публичный контракт market.v_houses (мигр. 154 прямо называет удаление колонки ломающим изменением). Принято осознанно — читателей вне tradein-mvp нет (git grep пуст, в т.ч. newbuilding_crossload.py). CREATE OR REPLACE VIEW колонку не удаляет, поэтому DROP VIEW → DROP COLUMN → CREATE VIEW, и вместе с DROP VIEW теряются гранты: GRANT SELECT для gendesign_reader восстановлен явно. Оговорка честности: сырой HTML прошлых сборов не хранится (raw_payload держит только body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не опровергается — но и в измеренной истории 1536 страниц положительных ноль. Refs #2674
214 lines
7 KiB
Python
214 lines
7 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 _history_rows(db) -> list[dict]:
|
||
"""Строки батча house_placement_history из мока сессии.
|
||
|
||
#2674: раньше тесты брали `db.execute.call_args_list[0]` — позиционно. Позиция
|
||
сломалась, как только у функции появился второй execute перед вставкой истории.
|
||
Фильтруем по SQL: тест переживёт любой новый вызов.
|
||
"""
|
||
for call in db.execute.call_args_list:
|
||
if "INSERT INTO house_placement_history" in str(call.args[0]):
|
||
return call.args[1]
|
||
return []
|
||
|
||
|
||
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)
|
||
|
||
# История — один execute со list-of-dicts (executemany, один round-trip).
|
||
rows = _history_rows(db)
|
||
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 = _history_rows(db)
|
||
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 = _history_rows(db)
|
||
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 = _history_rows(db)
|
||
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"
|