fix(tradein): панорама для страниц без истории, точные счётчики, окно без гонки (#2674)
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
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
Правки по ревью 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
This commit is contained in:
parent
f5b39e6fc9
commit
3d38d589d0
6 changed files with 153 additions and 50 deletions
|
|
@ -827,10 +827,15 @@ def _save_yandex_history_items(
|
||||||
(address|publish_date|area|floor|prices) hash.
|
(address|publish_date|area|floor|prices) hash.
|
||||||
|
|
||||||
Batch semantics: single try/except; on any failure the batch rolls back.
|
Batch semantics: single try/except; on any failure the batch rolls back.
|
||||||
"""
|
|
||||||
if not result.history_items:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
#2674 (ревью): резолв дома и запись houses.has_panorama идут ДО раннего возврата по
|
||||||
|
пустой истории. Раньше возврат стоял первым, и страница, отрисованная идеально, но
|
||||||
|
без единого объявления в истории, до записи панорамы не доходила — на проде это
|
||||||
|
1519 оценок против 1360 домов с историей, ~10% страниц молча пропускались. Цена
|
||||||
|
переноса: match_or_create_house теперь вызывается и для таких страниц (может
|
||||||
|
СОЗДАТЬ дом). Это тот же вызов, с тем же адресом, что уже отрабатывает на
|
||||||
|
остальных 90% — новых сущностей класс не появляется, появляется недостающая доля.
|
||||||
|
"""
|
||||||
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
|
||||||
# — stable across re-runs, distinguishes pages for different addresses.
|
# — stable across re-runs, distinguishes pages for different addresses.
|
||||||
address_seed = (result.address or "").strip().lower()
|
address_seed = (result.address or "").strip().lower()
|
||||||
|
|
@ -866,6 +871,12 @@ def _save_yandex_history_items(
|
||||||
result.address,
|
result.address,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Наблюдение о доме не зависит от того, есть ли на странице история объявлений.
|
||||||
|
_save_yandex_house_panorama(db, house_id, result.house)
|
||||||
|
|
||||||
|
if not result.history_items:
|
||||||
|
return 0
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
skipped_area = 0
|
skipped_area = 0
|
||||||
for item in result.history_items:
|
for item in result.history_items:
|
||||||
|
|
@ -936,7 +947,6 @@ def _save_yandex_history_items(
|
||||||
if rows:
|
if rows:
|
||||||
db.execute(sql, rows)
|
db.execute(sql, rows)
|
||||||
db.commit()
|
db.commit()
|
||||||
_save_yandex_house_panorama(db, house_id, result.house)
|
|
||||||
return len(rows)
|
return len(rows)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем.
|
-- 2. МЁРТВОЕ — механизм невыразим, дублирует существующее или потерял смысл. Удаляем.
|
||||||
-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня.
|
-- 3. ЗАДЕЛ — оставляем, но в схеме должно быть написано, чем он НЕ является сегодня.
|
||||||
--
|
--
|
||||||
-- Все числа — с прод-БД tradein 2026-08-06.
|
-- Все числа — с прод-БД tradein 2026-08-06, ТОЧНЫМ count(*). Первая редакция несла
|
||||||
|
-- сюда reltuples-оценки планировщика (listings «142 569» против реальных 93 408 —
|
||||||
|
-- раздув мёртвыми кортежами на 53%); в постоянном комментарии к схеме оценке не место,
|
||||||
|
-- тем более в PR, тезис которого — «каждое утверждение несёт число с прода».
|
||||||
--
|
--
|
||||||
-- ── 1. ПОДКЛЮЧАЕМ ───────────────────────────────────────────────────────────
|
-- ── 1. ПОДКЛЮЧАЕМ ───────────────────────────────────────────────────────────
|
||||||
--
|
--
|
||||||
|
|
@ -39,20 +42,20 @@
|
||||||
-- получить оценку по устаревшему рынку. Методика не потеряна: derivation-CTE
|
-- получить оценку по устаревшему рынку. Методика не потеряна: derivation-CTE
|
||||||
-- целиком сохранён в 098, восстановить = переприменить файл.
|
-- целиком сохранён в 098, восстановить = переприменить файл.
|
||||||
--
|
--
|
||||||
-- D. listings.merged_into — 142 569 строк, NULL у всех, ноль упоминаний в коде.
|
-- D. listings.merged_into — 93 408 строк, NULL у всех, ноль упоминаний в коде.
|
||||||
-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже
|
-- Заведена в 028 «under dedup workflow», который так и не построили; 113 уже
|
||||||
-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт
|
-- писала прямым текстом «column is dead, no code writer». Дедуп объявлений живёт
|
||||||
-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки,
|
-- в другом месте и по-другому (estimator._union_find_phys_dedup, во время оценки,
|
||||||
-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у
|
-- без записи в БД). Соседнюю listings.canonical НЕ трогаем — она вырождена (t у
|
||||||
-- всех 142 569), но её читает WHERE listings_search_mv (050/094), и снос колонки
|
-- всех 93 408), но её читает WHERE listings_search_mv (050/094), и снос колонки
|
||||||
-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша.
|
-- потянул бы пересоздание matview с шестью индексами ради нулевого выигрыша.
|
||||||
--
|
--
|
||||||
-- E. house_sources.raw_payload + GIN-индекс по нему — 46 813 строк, NULL у всех.
|
-- E. house_sources.raw_payload + GIN-индекс по нему — 49 502 строки, NULL у всех.
|
||||||
-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493)
|
-- Оба писателя house_sources (matching/houses.py:556, house_dedup_merge.py:493)
|
||||||
-- эту колонку в INSERT не включают; читателей нет, из публичного контракта
|
-- эту колонку в INSERT не включают; читателей нет, из публичного контракта
|
||||||
-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке,
|
-- market.v_house_sources (154) она намеренно исключена. GIN-индекс по колонке,
|
||||||
-- которая всегда NULL, — чистая стоимость на каждой вставке.
|
-- которая всегда NULL, — чистая стоимость на каждой вставке.
|
||||||
-- Соседний house_sources.ext_url тоже пуст 46 813/46 813, но он ВХОДИТ в
|
-- Соседний house_sources.ext_url тоже пуст 49 502/49 502, но он ВХОДИТ в
|
||||||
-- market.v_house_sources — удаление сломало бы обещание стабильности контракта.
|
-- market.v_house_sources — удаление сломало бы обещание стабильности контракта.
|
||||||
-- Оставляем и подписываем (см. п. 3).
|
-- Оставляем и подписываем (см. п. 3).
|
||||||
--
|
--
|
||||||
|
|
@ -97,9 +100,19 @@ BEGIN;
|
||||||
-- interval_days = 7: реестр капремонта не меняется ежедневно, а прогон качает два
|
-- interval_days = 7: реестр капремонта не меняется ежедневно, а прогон качает два
|
||||||
-- zip и парсит ~30 тыс. строк. Недельный такт достаточен и не жжёт трафик впустую.
|
-- zip и парсит ~30 тыс. строк. Недельный такт достаточен и не жжёт трафик впустую.
|
||||||
-- Ключ читает compute_next_run_at из default_params (см. 129).
|
-- Ключ читает compute_next_run_at из default_params (см. 129).
|
||||||
-- Окно 03:00-04:00 UTC — до rosreestr_dkp_import (04:00-06:00) и до
|
-- Окно 01:00-02:00 UTC. Первая редакция ставила 03:00-04:00 — ровно туда, где сидит
|
||||||
-- asking_to_sold_ratio_refresh (06:00-07:00): год постройки должен доехать в
|
-- refresh_search_matview (сверено с прод-таблицей scrape_schedules), то есть именно
|
||||||
-- listings раньше, чем по ним считают дневные агрегаты.
|
-- то задание, которое и переносит year_built в поиск. Планировщик берёт случайный
|
||||||
|
-- момент внутри окна и гоняет источники ПАРАЛЛЕЛЬНО, порядок он не гарантирует
|
||||||
|
-- ничем — совпадение окон превращало «сначала загрузка, потом обновление поиска»
|
||||||
|
-- в подбрасывание монеты. Час до 02:00 разводит их при типовой длительности прогона
|
||||||
|
-- и остаётся раньше rosreestr_dkp_import (04:00-06:00) и
|
||||||
|
-- asking_to_sold_ratio_refresh (06:00-07:00).
|
||||||
|
-- ЧЕСТНАЯ ОГОВОРКА: гарантии всё равно нет — при аномально долгом прогоне (сеть
|
||||||
|
-- ДОМ.РФ, ретраи) свежий year_built доедет до поиска на цикл позже. Ни блокировок,
|
||||||
|
-- ни потери данных: следующее обновление matview его подхватит.
|
||||||
|
-- Соседи в 01:00-02:00 — listing_source_snapshot и avito_city_sweep_kamensk_uralskiy;
|
||||||
|
-- общих ресурсов нет (ДОМ.РФ ходит своим httpx, мимо прокси-пула).
|
||||||
INSERT INTO scrape_schedules (
|
INSERT INTO scrape_schedules (
|
||||||
source,
|
source,
|
||||||
enabled,
|
enabled,
|
||||||
|
|
@ -112,9 +125,9 @@ VALUES
|
||||||
(
|
(
|
||||||
'domrf_kapremont_load',
|
'domrf_kapremont_load',
|
||||||
true,
|
true,
|
||||||
3,
|
1,
|
||||||
4,
|
2,
|
||||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC',
|
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||||
'{"interval_days": 7}'::jsonb
|
'{"interval_days": 7}'::jsonb
|
||||||
)
|
)
|
||||||
ON CONFLICT (source) DO NOTHING;
|
ON CONFLICT (source) DO NOTHING;
|
||||||
|
|
@ -209,7 +222,7 @@ COMMENT ON VIEW v_cross_source_health IS
|
||||||
'источников не подключено. Читателей в коде нет.';
|
'источников не подключено. Читателей в коде нет.';
|
||||||
|
|
||||||
COMMENT ON COLUMN house_sources.ext_url IS
|
COMMENT ON COLUMN house_sources.ext_url IS
|
||||||
'#2674: NULL у всех 46 813 строк — ни один из двух писателей house_sources '
|
'#2674: NULL у всех 49 502 строк — ни один из двух писателей house_sources '
|
||||||
'(matching/houses.py, house_dedup_merge.py) эту колонку не заполняет. НЕ удалена '
|
'(matching/houses.py, house_dedup_merge.py) эту колонку не заполняет. НЕ удалена '
|
||||||
'только потому, что входит в публичный контракт market.v_house_sources (мигр. 154), '
|
'только потому, что входит в публичный контракт market.v_house_sources (мигр. 154), '
|
||||||
'где удаление колонки объявлено ломающим изменением. Соседний raw_payload из '
|
'где удаление колонки объявлено ломающим изменением. Соседний raw_payload из '
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,28 @@ def test_has_panorama_false_written_when_page_rendered() -> None:
|
||||||
assert _panorama_updates(db) == [{"hid": 7, "panorama": False}]
|
assert _panorama_updates(db) == [{"hid": 7, "panorama": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_panorama_written_when_page_has_no_history() -> None:
|
||||||
|
"""Отрисованная страница БЕЗ истории объявлений — ~10% случаев на проде.
|
||||||
|
|
||||||
|
Ревью #2689: вызов стоял после раннего возврата по пустой истории, поэтому такие
|
||||||
|
страницы молча пропускались (1519 оценок против 1360 домов с историей). Наблюдение
|
||||||
|
о доме к наличию объявлений отношения не имеет.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
result = _result_with_meta(
|
||||||
|
ValuationHouseMeta(year_built=2015, total_floors=25, has_panorama=True)
|
||||||
|
)
|
||||||
|
result.history_items = []
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.estimator.match_or_create_house",
|
||||||
|
return_value=(42, 0.9, "fp"),
|
||||||
|
):
|
||||||
|
assert _save_yandex_history_items(db, result) == 0
|
||||||
|
|
||||||
|
assert _panorama_updates(db) == [{"hid": 42, "panorama": True}]
|
||||||
|
|
||||||
|
|
||||||
def test_has_panorama_not_written_when_page_unconfirmed() -> None:
|
def test_has_panorama_not_written_when_page_unconfirmed() -> None:
|
||||||
"""Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false."""
|
"""Пустая мета (капча/редизайн) → NULL, а не сфабрикованный false."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -160,6 +182,26 @@ def test_domrf_loader_is_seeded_into_schedules() -> None:
|
||||||
assert '"interval_days": 7' in sql
|
assert '"interval_days": 7' in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_domrf_window_does_not_collide_with_matview_refresh() -> None:
|
||||||
|
"""Окно ДОМ.РФ не должно совпадать с refresh_search_matview (03:00-04:00 UTC).
|
||||||
|
|
||||||
|
Ревью #2689: планировщик берёт случайный момент внутри окна и гоняет источники
|
||||||
|
параллельно — общее окно с тем заданием, которое переносит year_built в поиск,
|
||||||
|
это подбрасывание монеты. Тест ловит откат окна обратно на 3.
|
||||||
|
"""
|
||||||
|
sql = MIGRATION.read_text(encoding="utf-8")
|
||||||
|
values = sql.split("'domrf_kapremont_load',", 1)[1].split(")", 1)[0]
|
||||||
|
tokens = [t.strip().rstrip(",") for t in values.splitlines()]
|
||||||
|
hours = [int(t) for t in tokens if t.isdigit()]
|
||||||
|
assert hours, "не нашли window_start_hour/window_end_hour в INSERT"
|
||||||
|
start, end = hours[0], hours[1]
|
||||||
|
matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06
|
||||||
|
assert end <= matview_start or start >= matview_end, (
|
||||||
|
f"окно {start}-{end} пересекается с refresh_search_matview "
|
||||||
|
f"{matview_start}-{matview_end}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_domrf_handler_reuses_loader_functions() -> None:
|
def test_domrf_handler_reuses_loader_functions() -> None:
|
||||||
"""Дизайн-инвариант product_handlers: job переиспользует боевое тело, не копирует."""
|
"""Дизайн-инвариант product_handlers: job переиспользует боевое тело, не копирует."""
|
||||||
src = (TRADEIN / "backend" / "app" / "services" / "product_handlers.py").read_text(
|
src = (TRADEIN / "backend" / "app" / "services" / "product_handlers.py").read_text(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ from app.services.estimator import (
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
|
|
||||||
|
|
||||||
|
def _history_rows(db) -> list[dict]:
|
||||||
|
"""Строки батча house_placement_history из мока сессии (фильтр по 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 _sample_result(address: str = "Екатеринбург, ул. Учителей, 18") -> YandexValuationResult:
|
def _sample_result(address: str = "Екатеринбург, ул. Учителей, 18") -> YandexValuationResult:
|
||||||
return YandexValuationResult(
|
return YandexValuationResult(
|
||||||
address=address,
|
address=address,
|
||||||
|
|
@ -172,17 +180,20 @@ def test_save_history_items_inserts_each():
|
||||||
assert saved == 2
|
assert saved == 2
|
||||||
# 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE
|
# 1 batch INSERT (executemany). #2674 добавил вторым вызовом UPDATE
|
||||||
# houses.has_panorama — считаем именно вставки истории, а не все execute.
|
# houses.has_panorama — считаем именно вставки истории, а не все execute.
|
||||||
inserts = [
|
rows = _history_rows(db)
|
||||||
c
|
|
||||||
for c in db.execute.call_args_list
|
|
||||||
if "INSERT INTO house_placement_history" in str(c.args[0])
|
|
||||||
]
|
|
||||||
assert len(inserts) == 1
|
|
||||||
rows = inserts[0].args[1]
|
|
||||||
assert isinstance(rows, list) and len(rows) == 2
|
assert isinstance(rows, list) and len(rows) == 2
|
||||||
|
# Два коммита: панорама (до истории) + батч истории. Раньше был один.
|
||||||
|
assert db.commit.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_save_history_items_empty_no_commit():
|
def test_save_history_items_empty_no_commit():
|
||||||
|
"""Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего.
|
||||||
|
|
||||||
|
#2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно
|
||||||
|
отрезал запись houses.has_panorama для отрисованных страниц без объявлений (~10%).
|
||||||
|
Теперь резолв дома идёт до возврата, поэтому match_or_create_house вызывается —
|
||||||
|
а вот записей по-прежнему ноль: мета пустая, гейт панорамы не пропускает.
|
||||||
|
"""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
result = YandexValuationResult(
|
result = YandexValuationResult(
|
||||||
address="x",
|
address="x",
|
||||||
|
|
@ -193,11 +204,13 @@ def test_save_history_items_empty_no_commit():
|
||||||
house=ValuationHouseMeta(),
|
house=ValuationHouseMeta(),
|
||||||
history_items=[],
|
history_items=[],
|
||||||
)
|
)
|
||||||
# match_or_create_house must NOT be called when there are no items (early return)
|
with patch(
|
||||||
with patch("app.services.estimator.match_or_create_house") as m:
|
"app.services.estimator.match_or_create_house",
|
||||||
|
return_value=(1, 0.9, "fingerprint"),
|
||||||
|
) as m:
|
||||||
saved = _save_yandex_history_items(db, result)
|
saved = _save_yandex_history_items(db, result)
|
||||||
assert saved == 0
|
assert saved == 0
|
||||||
m.assert_not_called()
|
m.assert_called_once()
|
||||||
db.execute.assert_not_called()
|
db.execute.assert_not_called()
|
||||||
db.commit.assert_not_called()
|
db.commit.assert_not_called()
|
||||||
|
|
||||||
|
|
@ -231,9 +244,20 @@ def test_save_history_items_ext_id_stable_across_calls():
|
||||||
|
|
||||||
|
|
||||||
def test_save_history_items_db_error_rolls_back_batch():
|
def test_save_history_items_db_error_rolls_back_batch():
|
||||||
"""Any item failing rolls back the whole batch — batch semantics (finding #5)."""
|
"""Any item failing rolls back the whole batch — batch semantics (finding #5).
|
||||||
|
|
||||||
|
#2674: side_effect адресуем по SQL, а не по позиции вызова — иначе исключение
|
||||||
|
доставалось бы UPDATE houses.has_panorama (он идёт первым и свои ошибки глотает),
|
||||||
|
а батч истории проходил бы успешно, и тест молча проверял бы не тот путь.
|
||||||
|
"""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.side_effect = [RuntimeError("first row fails"), None]
|
|
||||||
|
def _fail_history(sql, *args, **kwargs):
|
||||||
|
if "INSERT INTO house_placement_history" in str(sql):
|
||||||
|
raise RuntimeError("first row fails")
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
db.execute.side_effect = _fail_history
|
||||||
result = _sample_result()
|
result = _sample_result()
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.estimator.match_or_create_house",
|
"app.services.estimator.match_or_create_house",
|
||||||
|
|
@ -242,4 +266,5 @@ def test_save_history_items_db_error_rolls_back_batch():
|
||||||
saved = _save_yandex_history_items(db, result)
|
saved = _save_yandex_history_items(db, result)
|
||||||
assert saved == 0 # whole batch rolled back
|
assert saved == 0 # whole batch rolled back
|
||||||
db.rollback.assert_called_once()
|
db.rollback.assert_called_once()
|
||||||
db.commit.assert_not_called()
|
# Панорама коммитится отдельно и раньше — её успех не отменяет отката истории.
|
||||||
|
assert db.commit.call_count == 1
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ from scraper_kit.providers.yandex.valuation import (
|
||||||
from app.services.estimator import _save_yandex_history_items
|
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:
|
def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult:
|
||||||
return YandexValuationResult(
|
return YandexValuationResult(
|
||||||
address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106",
|
address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106",
|
||||||
|
|
@ -84,7 +97,7 @@ def test_item_with_area_none_is_skipped() -> None:
|
||||||
saved = _save_yandex_history_items(db, result)
|
saved = _save_yandex_history_items(db, result)
|
||||||
|
|
||||||
assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}"
|
assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}"
|
||||||
rows = db.execute.call_args_list[0].args[1]
|
rows = _history_rows(db)
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["area"] == 50.0
|
assert rows[0]["area"] == 50.0
|
||||||
|
|
||||||
|
|
@ -109,7 +122,7 @@ def test_item_with_area_zero_is_skipped() -> None:
|
||||||
saved = _save_yandex_history_items(db, result)
|
saved = _save_yandex_history_items(db, result)
|
||||||
|
|
||||||
assert saved == 1
|
assert saved == 1
|
||||||
rows = db.execute.call_args_list[0].args[1]
|
rows = _history_rows(db)
|
||||||
assert rows[0]["area"] == 55.0
|
assert rows[0]["area"] == 55.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -155,12 +168,7 @@ def test_all_invalid_area_returns_zero_no_crash() -> None:
|
||||||
|
|
||||||
assert saved == 0
|
assert saved == 0
|
||||||
# db.execute не должен вызываться для пустого rows (нет INSERT)
|
# db.execute не должен вызываться для пустого rows (нет INSERT)
|
||||||
inserts = [
|
assert _history_rows(db) == []
|
||||||
c
|
|
||||||
for c in db.execute.call_args_list
|
|
||||||
if "INSERT INTO house_placement_history" in str(c.args[0])
|
|
||||||
]
|
|
||||||
assert inserts == []
|
|
||||||
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
|
# Commit вызывается, rollback — нет. Два коммита: пустой батч истории + запись
|
||||||
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
|
# houses.has_panorama (#2674) — наблюдение о доме не зависит от того, отфильтровалась
|
||||||
# ли история по площади.
|
# ли история по площади.
|
||||||
|
|
@ -208,7 +216,7 @@ def test_mixed_items_only_valid_saved() -> None:
|
||||||
saved = _save_yandex_history_items(db, result)
|
saved = _save_yandex_history_items(db, result)
|
||||||
|
|
||||||
assert saved == 2
|
assert saved == 2
|
||||||
rows = db.execute.call_args_list[0].args[1]
|
rows = _history_rows(db)
|
||||||
assert len(rows) == 2
|
assert len(rows) == 2
|
||||||
areas = {r["area"] for r in rows}
|
areas = {r["area"] for r in rows}
|
||||||
assert areas == {40.0, 60.0}
|
assert areas == {40.0, 60.0}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,19 @@ from scraper_kit.providers.yandex.valuation import (
|
||||||
from app.services.estimator import _save_yandex_history_items
|
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:
|
def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult:
|
||||||
return YandexValuationResult(
|
return YandexValuationResult(
|
||||||
address="Россия, Свердловская область, Екатеринбург, улица Куйбышева, 106",
|
address="Россия, Свердловская область, Екатеринбург, улица Куйбышева, 106",
|
||||||
|
|
@ -91,15 +104,7 @@ def test_save_row_contains_house_id_and_confidence():
|
||||||
_save_yandex_history_items(db, result)
|
_save_yandex_history_items(db, result)
|
||||||
|
|
||||||
# История — один execute со list-of-dicts (executemany, один round-trip).
|
# История — один execute со list-of-dicts (executemany, один round-trip).
|
||||||
# #2674 добавил отдельный UPDATE houses.has_panorama — фильтруем по SQL, а не
|
rows = _history_rows(db)
|
||||||
# по порядковому номеру вызова.
|
|
||||||
inserts = [
|
|
||||||
c
|
|
||||||
for c in db.execute.call_args_list
|
|
||||||
if "INSERT INTO house_placement_history" in str(c.args[0])
|
|
||||||
]
|
|
||||||
assert len(inserts) == 1
|
|
||||||
rows = inserts[0].args[1]
|
|
||||||
assert isinstance(rows, list) and len(rows) == 2
|
assert isinstance(rows, list) and len(rows) == 2
|
||||||
for row in rows:
|
for row in rows:
|
||||||
assert row["house_id"] == 54321
|
assert row["house_id"] == 54321
|
||||||
|
|
@ -132,7 +137,7 @@ def test_save_row_contains_removed_date():
|
||||||
_save_yandex_history_items(db, result)
|
_save_yandex_history_items(db, result)
|
||||||
|
|
||||||
# args[1] is now the list-of-dicts passed to executemany
|
# args[1] is now the list-of-dicts passed to executemany
|
||||||
rows = db.execute.call_args_list[0].args[1]
|
rows = _history_rows(db)
|
||||||
assert rows[0]["removed_date"] == date(2024, 5, 20)
|
assert rows[0]["removed_date"] == date(2024, 5, 20)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,7 +164,7 @@ def test_save_row_removed_date_none_when_active():
|
||||||
):
|
):
|
||||||
_save_yandex_history_items(db, result)
|
_save_yandex_history_items(db, result)
|
||||||
|
|
||||||
rows = db.execute.call_args_list[0].args[1]
|
rows = _history_rows(db)
|
||||||
assert rows[0]["removed_date"] is None
|
assert rows[0]["removed_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -187,7 +192,7 @@ def test_save_handles_match_failure_gracefully():
|
||||||
saved = _save_yandex_history_items(db, result)
|
saved = _save_yandex_history_items(db, result)
|
||||||
|
|
||||||
assert saved == 1
|
assert saved == 1
|
||||||
rows = db.execute.call_args_list[0].args[1]
|
rows = _history_rows(db)
|
||||||
assert rows[0]["house_id"] is None
|
assert rows[0]["house_id"] is None
|
||||||
assert rows[0]["confidence"] == pytest.approx(0.0)
|
assert rows[0]["confidence"] == pytest.approx(0.0)
|
||||||
assert rows[0]["notes"] is None
|
assert rows[0]["notes"] is None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue