gendesign/tradein-mvp/backend/tests/test_yandex_valuation_save.py
bot-backend 3d38d589d0
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 3m0s
fix(tradein): панорама для страниц без истории, точные счётчики, окно без гонки (#2674)
Правки по ревью PR #2689.

Признак панорамы был недостижим примерно для десятой части страниц. Вызов стоял
после раннего возврата по пустой истории размещений, поэтому идеально отрисованная
страница без единого объявления до записи не доходила: на проде 1519 оценок против
1360 домов с историей. Резолв дома и запись панорамы подняты выше возврата — гейт
честности не тронут. Цена: match_or_create_house теперь вызывается и для таких
страниц (может создать дом), но это тот же вызов с тем же адресом, который уже
отрабатывает на остальных 90%.

Числа в комментариях к схеме были оценками планировщика, а не точным счётом:
listings 142 569 против реальных 93 408 (раздув мёртвыми кортежами на 53%),
house_sources 46 813 против 49 502. На безопасность удаления это не влияло — нули
там точные, — но оценка уезжала в постоянный комментарий к схеме, в PR, тезис
которого «каждое утверждение несёт число с прода». Пересчитано точным count(*).

Окно расписания ДОМ.РФ 03:00-04:00 совпадало с refresh_search_matview — то есть
ровно с тем заданием, которое переносит year_built в поиск. Планировщик берёт
случайный момент внутри окна и гоняет источники параллельно, так что порядок был
подбрасыванием монеты. Перенесено на 01:00-02:00; в комментарии честно сказано, что
гарантии всё равно нет и при аномально долгом прогоне возможно отставание на цикл.

Тесты, адресовавшие вызовы по позиции (db.execute.call_args_list[0]), переведены на
фильтр по SQL — это и была причина, по которой добавление второго execute ломало
шесть чужих тестов разом. То же для side_effect в тесте отката батча: исключение
доставалось бы записи панорамы, которая свои ошибки глотает, и тест молча проверял
бы не тот путь. В test_save_history_items_inserts_each возвращено утверждение о
числе коммитов (было удалено вместо обновления).

Refs #2674
2026-08-06 05:32:14 +05:00

214 lines
7.1 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 (UPDATE houses.has_panorama
перед вставкой истории). Фильтруем по 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"