All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
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 47s
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5) убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main. Заодно: - удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/), импортировавших уже-удалённые или удаляемые сейчас legacy-модули - тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.* эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete golden-parity тестов, потерявших legacy-оракл - kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config — ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(), MagicMock HouseMatcher, region_code=66) Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка #2208 (test_search_cache_hit, не связан со scrapers).
472 lines
18 KiB
Python
472 lines
18 KiB
Python
"""Тесты для snapshot_writer.py и интеграции с save_detail_enrichment / save_listings.
|
||
|
||
Цель: доказать что:
|
||
1. upsert_listing_snapshot строит корректный INSERT INTO listings_snapshots.
|
||
2. Повторный вызов с теми же listing_id + snapshot_date НЕ дублирует строку
|
||
(ON CONFLICT DO UPDATE обновляет observed_at).
|
||
3. save_detail_enrichment вызывает snapshot-write после SELECT price из listings.
|
||
4. save_listings вызывает snapshot-write для каждого сохранённого listing.
|
||
5. Ошибка snapshot не прерывает основной INSERT в save_listings.
|
||
|
||
Используем unittest.mock — без реальной БД.
|
||
|
||
`app.services.scrapers.cian_detail` удалён (#2397 Part E2, 0 runtime-импортёров) —
|
||
`DetailEnrichment`/`save_detail_enrichment` ретаргетированы на
|
||
`scraper_kit.providers.cian.detail` (byte-identical strangler-копия).
|
||
|
||
`app.services.scrapers.base`/`snapshot_writer` удалены (#2397 финальный шаг E, 0
|
||
runtime-импортёров) — `upsert_listing_snapshot`/`ScrapedLot`/`save_listings`
|
||
переведены на `scraper_kit.snapshot_writer`/`scraper_kit.base`. Kit `save_listings`
|
||
принимает инжектируемый `matcher: HouseMatcher` (mandatory kwarg, strangler DI
|
||
#2133) вместо прямого импорта `match_or_create_house`/`upsert_listing_source` —
|
||
тесты используют `MagicMock()` matcher (тот же паттерн, что
|
||
`test_scraped_at_bump_2206.py::_kit_matcher`), плюс mandatory `region_code=66`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from contextlib import contextmanager
|
||
from datetime import date
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from scraper_kit.base import ScrapedLot, save_listings
|
||
from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment
|
||
from scraper_kit.snapshot_writer import upsert_listing_snapshot
|
||
|
||
|
||
def _mock_matcher() -> MagicMock:
|
||
"""MagicMock HouseMatcher — mirror test_scraped_at_bump_2206.py::_kit_matcher."""
|
||
matcher = MagicMock()
|
||
matcher.match_or_create_house.return_value = (1, 1.0, "new")
|
||
matcher.upsert_listing_source.return_value = None
|
||
return matcher
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _mock_db_simple() -> MagicMock:
|
||
"""Минимальный mock db.execute — scalar None для всех вызовов."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
db.execute.return_value.fetchone.return_value = None
|
||
db.execute.return_value.scalar_one_or_none.return_value = None
|
||
|
||
@contextmanager
|
||
def _nested():
|
||
yield MagicMock()
|
||
|
||
db.begin_nested.side_effect = _nested
|
||
return db
|
||
|
||
|
||
def _get_all_sqls(db: MagicMock) -> list[str]:
|
||
"""Собрать все SQL-строки из всех db.execute() вызовов."""
|
||
sqls = []
|
||
for call_args in db.execute.call_args_list:
|
||
if call_args.args:
|
||
sqls.append(str(call_args.args[0]))
|
||
return sqls
|
||
|
||
|
||
def _get_all_params(db: MagicMock) -> list[dict]:
|
||
"""Собрать все params dict из db.execute() вызовов."""
|
||
params_list = []
|
||
for call_args in db.execute.call_args_list:
|
||
if len(call_args.args) >= 2:
|
||
params_list.append(call_args.args[1])
|
||
return params_list
|
||
|
||
|
||
def _find_snapshot_call(db: MagicMock) -> tuple[str, dict] | None:
|
||
"""Найти первый db.execute вызов содержащий 'listings_snapshots'."""
|
||
for call_args in db.execute.call_args_list:
|
||
if not call_args.args:
|
||
continue
|
||
sql = str(call_args.args[0])
|
||
if "listings_snapshots" in sql:
|
||
params = call_args.args[1] if len(call_args.args) >= 2 else {}
|
||
return sql, params
|
||
return None
|
||
|
||
|
||
# ── upsert_listing_snapshot — SQL shape ──────────────────────────────────────
|
||
|
||
|
||
def test_upsert_snapshot_sql_contains_insert_and_on_conflict():
|
||
"""SQL содержит INSERT INTO listings_snapshots и ON CONFLICT (listing_id, snapshot_date)."""
|
||
db = _mock_db_simple()
|
||
upsert_listing_snapshot(db, listing_id=42, price_rub=5_000_000)
|
||
|
||
sqls = _get_all_sqls(db)
|
||
assert len(sqls) == 1, "ровно один db.execute вызов"
|
||
sql = sqls[0]
|
||
assert "listings_snapshots" in sql
|
||
assert "ON CONFLICT" in sql
|
||
assert "DO UPDATE" in sql
|
||
|
||
|
||
def test_upsert_snapshot_params_minimal():
|
||
"""Минимальный вызов: только listing_id + price_rub — остальные None."""
|
||
db = _mock_db_simple()
|
||
upsert_listing_snapshot(db, listing_id=7, price_rub=3_000_000)
|
||
|
||
params = _get_all_params(db)
|
||
assert len(params) == 1
|
||
p = params[0]
|
||
assert p["lid"] == 7
|
||
assert p["price"] == 3_000_000
|
||
assert p["run_id"] is None
|
||
assert p["snap_date"] is None
|
||
assert p["ppm2"] is None
|
||
assert p["pos"] is None
|
||
assert p["status"] == "active"
|
||
|
||
|
||
def test_upsert_snapshot_params_full():
|
||
"""Полный вызов — все поля правильно попадают в params."""
|
||
db = _mock_db_simple()
|
||
snap_date = date(2026, 5, 1)
|
||
upsert_listing_snapshot(
|
||
db,
|
||
listing_id=100,
|
||
price_rub=7_200_000,
|
||
price_per_m2=130_000,
|
||
run_id=99,
|
||
snapshot_date=snap_date,
|
||
position_in_serp=3,
|
||
status="active",
|
||
)
|
||
|
||
params = _get_all_params(db)[0]
|
||
assert params["lid"] == 100
|
||
assert params["price"] == 7_200_000
|
||
assert params["ppm2"] == 130_000
|
||
assert params["run_id"] == 99
|
||
assert params["snap_date"] == snap_date
|
||
assert params["pos"] == 3
|
||
assert params["status"] == "active"
|
||
|
||
|
||
def test_upsert_snapshot_no_commit():
|
||
"""upsert_listing_snapshot НЕ вызывает db.commit() — commit делает caller."""
|
||
db = _mock_db_simple()
|
||
upsert_listing_snapshot(db, listing_id=1, price_rub=1_000_000)
|
||
db.commit.assert_not_called()
|
||
|
||
|
||
# ── save_detail_enrichment интеграция ────────────────────────────────────────
|
||
|
||
|
||
def _mock_db_detail(price_rub: int = 5_500_000, price_per_m2: int | None = 110_000) -> MagicMock:
|
||
"""Mock для save_detail_enrichment: SELECT listings возвращает price_rub/price_per_m2."""
|
||
db = MagicMock()
|
||
|
||
# SELECT price_rub, price_per_m2 FROM listings
|
||
price_row = MagicMock()
|
||
price_row.price_rub = price_rub
|
||
price_row.price_per_m2 = price_per_m2
|
||
|
||
@contextmanager
|
||
def _nested():
|
||
yield MagicMock()
|
||
|
||
db.begin_nested.side_effect = _nested
|
||
|
||
# Разные вызовы db.execute() возвращают разное:
|
||
# 1. UPDATE listings → result.fetchone() = None (не используется)
|
||
# 2. SELECT price → result.fetchone() = price_row
|
||
# 3+. INSERT offer_price_history / agents → не важно
|
||
execute_results = []
|
||
|
||
def _execute_side_effect(sql, params=None):
|
||
sql_str = str(sql)
|
||
mock_result = MagicMock()
|
||
mock_result.scalar_one_or_none.return_value = None
|
||
if "SELECT price_rub" in sql_str:
|
||
mock_result.fetchone.return_value = price_row
|
||
else:
|
||
mock_result.fetchone.return_value = None
|
||
execute_results.append((sql_str, params, mock_result))
|
||
return mock_result
|
||
|
||
db.execute.side_effect = _execute_side_effect
|
||
db._execute_log = execute_results
|
||
return db
|
||
|
||
|
||
def test_save_detail_enrichment_writes_snapshot():
|
||
"""save_detail_enrichment вызывает upsert_listing_snapshot через SELECT listings."""
|
||
db = _mock_db_detail(price_rub=6_000_000, price_per_m2=120_000)
|
||
enrichment = DetailEnrichment(ceiling_height=2.7)
|
||
|
||
save_detail_enrichment(db, 55, enrichment)
|
||
|
||
snap = _find_snapshot_call(db)
|
||
assert snap is not None, "должен быть INSERT INTO listings_snapshots"
|
||
_sql, params = snap
|
||
assert params["lid"] == 55
|
||
assert params["price"] == 6_000_000
|
||
assert params["ppm2"] == 120_000
|
||
assert params["status"] == "active"
|
||
|
||
|
||
def test_save_detail_enrichment_snapshot_skipped_when_no_price_row():
|
||
"""Если listings строка не найдена (None) — snapshot не записывается, ошибки нет."""
|
||
db = MagicMock()
|
||
|
||
@contextmanager
|
||
def _nested():
|
||
yield MagicMock()
|
||
|
||
db.begin_nested.side_effect = _nested
|
||
|
||
def _exec(sql, params=None):
|
||
mock_result = MagicMock()
|
||
mock_result.fetchone.return_value = None
|
||
mock_result.scalar_one_or_none.return_value = None
|
||
return mock_result
|
||
|
||
db.execute.side_effect = _exec
|
||
|
||
enrichment = DetailEnrichment()
|
||
# Должно завершиться без исключений
|
||
save_detail_enrichment(db, 99, enrichment)
|
||
|
||
# Убедимся что listings_snapshots не упоминается
|
||
sqls = _get_all_sqls(db)
|
||
assert not any("listings_snapshots" in s for s in sqls)
|
||
|
||
|
||
def test_save_detail_enrichment_snapshot_failure_does_not_abort():
|
||
"""Если upsert_listing_snapshot падает — save_detail_enrichment всё равно commit'ит."""
|
||
db = _mock_db_detail(price_rub=4_000_000)
|
||
|
||
with patch(
|
||
"scraper_kit.providers.cian.detail.upsert_listing_snapshot",
|
||
side_effect=RuntimeError("db snapshot blip"),
|
||
):
|
||
enrichment = DetailEnrichment(ceiling_height=2.5)
|
||
# Должно завершиться без исключений — snapshot error логируется, не пробрасывается
|
||
save_detail_enrichment(db, 10, enrichment)
|
||
|
||
db.commit.assert_called_once()
|
||
|
||
|
||
def test_save_detail_enrichment_writes_offer_price_history_rows():
|
||
"""price_changes из enrichment записываются в offer_price_history."""
|
||
db = _mock_db_detail(price_rub=5_000_000)
|
||
enrichment = DetailEnrichment(
|
||
price_changes=[
|
||
{"change_time": "2026-04-01T00:00:00Z", "price_rub": 5_200_000, "diff_percent": None},
|
||
{"change_time": "2026-04-10T00:00:00Z", "price_rub": 5_000_000, "diff_percent": -3.8},
|
||
]
|
||
)
|
||
|
||
save_detail_enrichment(db, 20, enrichment)
|
||
|
||
sqls = _get_all_sqls(db)
|
||
oph_inserts = [s for s in sqls if "offer_price_history" in s]
|
||
assert len(oph_inserts) == 2, "по одному INSERT для каждой price_change"
|
||
|
||
|
||
def test_save_detail_enrichment_oph_on_conflict_constraint():
|
||
"""INSERT offer_price_history использует именованный ON CONFLICT ON CONSTRAINT."""
|
||
db = _mock_db_detail(price_rub=5_000_000)
|
||
enrichment = DetailEnrichment(
|
||
price_changes=[
|
||
{"change_time": "2026-05-01T12:00:00Z", "price_rub": 5_000_000, "diff_percent": 0},
|
||
]
|
||
)
|
||
|
||
save_detail_enrichment(db, 21, enrichment)
|
||
|
||
sqls = _get_all_sqls(db)
|
||
oph_sqls = [s for s in sqls if "offer_price_history" in s]
|
||
assert oph_sqls, "INSERT offer_price_history не найден"
|
||
assert (
|
||
"offer_price_history_listing_change_uq" in oph_sqls[0]
|
||
), "ON CONFLICT должен ссылаться на UNIQUE constraint"
|
||
|
||
|
||
def test_save_detail_enrichment_skips_price_change_without_change_time():
|
||
"""price_changes без change_time пропускаются, не вызывают INSERT."""
|
||
db = _mock_db_detail(price_rub=5_000_000)
|
||
enrichment = DetailEnrichment(
|
||
price_changes=[
|
||
{"change_time": None, "price_rub": 5_000_000}, # пропустить
|
||
{"change_time": "2026-04-01T00:00:00Z", "price_rub": None}, # пропустить
|
||
{"change_time": "2026-04-01T00:00:00Z", "price_rub": 5_000_000}, # записать
|
||
]
|
||
)
|
||
|
||
save_detail_enrichment(db, 22, enrichment)
|
||
|
||
sqls = _get_all_sqls(db)
|
||
oph_inserts = [s for s in sqls if "offer_price_history" in s]
|
||
assert len(oph_inserts) == 1
|
||
|
||
|
||
# ── save_listings интеграция ─────────────────────────────────────────────────
|
||
|
||
|
||
def _mock_db_for_save_listings(
|
||
inserted: bool = True,
|
||
listing_id: int = 42,
|
||
) -> MagicMock:
|
||
"""Mock db для save_listings с правильным RETURNING + begin_nested."""
|
||
row = MagicMock()
|
||
row.id = listing_id
|
||
row.inserted = inserted
|
||
|
||
ins_result = MagicMock()
|
||
ins_result.fetchone.return_value = row
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value = ins_result
|
||
|
||
@contextmanager
|
||
def _nested():
|
||
yield MagicMock()
|
||
|
||
db.begin_nested.side_effect = _nested
|
||
return db
|
||
|
||
|
||
def test_save_listings_writes_snapshot_per_lot():
|
||
"""save_listings вызывает upsert_listing_snapshot для каждого сохранённого lot."""
|
||
|
||
db = _mock_db_for_save_listings(inserted=True, listing_id=77)
|
||
lots = [
|
||
ScrapedLot(
|
||
source="cian",
|
||
source_url="https://ekb.cian.ru/sale/flat/111/",
|
||
source_id="111",
|
||
price_rub=5_000_000,
|
||
),
|
||
ScrapedLot(
|
||
source="cian",
|
||
source_url="https://ekb.cian.ru/sale/flat/222/",
|
||
source_id="222",
|
||
price_rub=6_000_000,
|
||
),
|
||
]
|
||
|
||
with patch("scraper_kit.base.upsert_listing_snapshot") as mock_snap:
|
||
save_listings(db, lots, matcher=_mock_matcher(), region_code=66, run_id=10)
|
||
|
||
assert mock_snap.call_count == 2, "snapshot должен вызваться для каждого lot"
|
||
# Первый вызов
|
||
kwargs0 = mock_snap.call_args_list[0].kwargs
|
||
assert kwargs0["listing_id"] == 77
|
||
assert kwargs0["price_rub"] == 5_000_000
|
||
assert kwargs0["run_id"] == 10
|
||
assert kwargs0["status"] == "active"
|
||
|
||
|
||
def test_save_listings_snapshot_receives_run_id():
|
||
"""Если передан run_id — он пробрасывается в upsert_listing_snapshot."""
|
||
|
||
db = _mock_db_for_save_listings(listing_id=88)
|
||
lot = ScrapedLot(
|
||
source="avito",
|
||
source_url="https://avito.ru/1",
|
||
source_id="1",
|
||
price_rub=3_000_000,
|
||
)
|
||
|
||
with patch("scraper_kit.base.upsert_listing_snapshot") as mock_snap:
|
||
save_listings(db, [lot], matcher=_mock_matcher(), region_code=66, run_id=55)
|
||
|
||
kwargs = mock_snap.call_args.kwargs
|
||
assert kwargs["run_id"] == 55
|
||
|
||
|
||
def test_save_listings_snapshot_none_run_id_by_default():
|
||
"""Без run_id — upsert_listing_snapshot получает run_id=None."""
|
||
|
||
db = _mock_db_for_save_listings(listing_id=9)
|
||
lot = ScrapedLot(
|
||
source="cian",
|
||
source_url="https://ekb.cian.ru/sale/flat/9/",
|
||
source_id="9",
|
||
price_rub=2_000_000,
|
||
)
|
||
|
||
with patch("scraper_kit.base.upsert_listing_snapshot") as mock_snap:
|
||
save_listings(db, [lot], matcher=_mock_matcher(), region_code=66) # no run_id
|
||
|
||
kwargs = mock_snap.call_args.kwargs
|
||
assert kwargs["run_id"] is None
|
||
|
||
|
||
def test_save_listings_snapshot_failure_does_not_abort_batch():
|
||
"""Ошибка snapshot не прерывает основной INSERT; все lots сохраняются."""
|
||
|
||
db = _mock_db_for_save_listings(inserted=True, listing_id=5)
|
||
lots = [
|
||
ScrapedLot(
|
||
source="cian",
|
||
source_url=f"https://ekb.cian.ru/sale/flat/{i}/",
|
||
source_id=str(i),
|
||
price_rub=1_000_000 * i,
|
||
)
|
||
for i in range(1, 4)
|
||
]
|
||
|
||
with patch(
|
||
"scraper_kit.base.upsert_listing_snapshot",
|
||
side_effect=RuntimeError("snapshot fail"),
|
||
):
|
||
inserted, _updated = save_listings(db, lots, matcher=_mock_matcher(), region_code=66)
|
||
|
||
# INSERT listings всё равно прошёл для всех 3 lots
|
||
assert inserted == 3
|
||
|
||
|
||
def test_save_listings_empty_no_snapshot_call():
|
||
"""Пустой список → upsert_listing_snapshot не вызывается."""
|
||
|
||
db = _mock_db_for_save_listings()
|
||
|
||
with patch("scraper_kit.base.upsert_listing_snapshot") as mock_snap:
|
||
save_listings(db, [], matcher=_mock_matcher(), region_code=66)
|
||
|
||
mock_snap.assert_not_called()
|
||
|
||
|
||
# ── Идемпотентность: повторный вызов тот же listing_id + дата ────────────────
|
||
|
||
|
||
def test_upsert_snapshot_idempotent_same_params_no_error():
|
||
"""Два одинаковых вызова (listing_id + date совпадают) — нет ошибок.
|
||
|
||
Реальная де-дупликация — ON CONFLICT DO UPDATE в БД.
|
||
Здесь проверяем что Python-код не падает при повторном вызове с теми же данными.
|
||
"""
|
||
db = _mock_db_simple()
|
||
snap_date = date(2026, 5, 28)
|
||
|
||
upsert_listing_snapshot(db, listing_id=1, price_rub=5_000_000, snapshot_date=snap_date)
|
||
upsert_listing_snapshot(db, listing_id=1, price_rub=5_000_000, snapshot_date=snap_date)
|
||
|
||
# Два вызова db.execute — каждый попытается INSERT ON CONFLICT DO UPDATE
|
||
assert db.execute.call_count == 2
|
||
# Убедимся что оба SQL содержат ON CONFLICT DO UPDATE
|
||
sqls = _get_all_sqls(db)
|
||
assert all("ON CONFLICT" in s for s in sqls)
|
||
|
||
|
||
def test_upsert_snapshot_different_dates_no_conflict():
|
||
"""Разные даты для одного listing → два отдельных snapshot (нет конфликта по PK)."""
|
||
db = _mock_db_simple()
|
||
|
||
upsert_listing_snapshot(db, listing_id=1, price_rub=5_000_000, snapshot_date=date(2026, 5, 1))
|
||
upsert_listing_snapshot(db, listing_id=1, price_rub=4_800_000, snapshot_date=date(2026, 5, 2))
|
||
|
||
# Каждый вызов делает INSERT: разные snapshot_date → разные PRIMARY KEY → нет конфликта
|
||
params_list = _get_all_params(db)
|
||
assert params_list[0]["snap_date"] == date(2026, 5, 1)
|
||
assert params_list[1]["snap_date"] == date(2026, 5, 2)
|
||
assert params_list[0]["price"] == 5_000_000
|
||
assert params_list[1]["price"] == 4_800_000
|