gendesign/tradein-mvp/backend/tests/test_yandex_valuation_save.py
bot-backend 3e30b97d6b
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
fix(tradein/yandex): снести признак «панорама» — его нет на площадке
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
2026-08-13 01:04:18 +05:00

214 lines
7 KiB
Python
Raw Permalink 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.

"""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"