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).
214 lines
8 KiB
Python
214 lines
8 KiB
Python
"""#2206: ре-подтверждение живого листинга двигает scraped_at (не только last_seen_at).
|
||
|
||
Корень бага: upsert листингов при ре-подтверждении живым ставил last_seen_at=NOW(),
|
||
обновлял цену/поля, но scraped_at НЕ трогал никогда. Эстиматор фильтрует свежесть
|
||
через `scraped_at > NOW() - INTERVAL '14 days'`, поэтому ре-подтверждённые (но не
|
||
пере-вставленные) листинги выпадали из видимого инвентаря — на проде до фикса
|
||
эстиматору было видно лишь ~45.6% активного инвентаря (CIAN — 5.9%).
|
||
|
||
Фикс: и ON CONFLICT DO UPDATE, и dedup-drift reconcile UPDATE теперь выставляют
|
||
scraped_at = NOW() рядом с last_seen_at = NOW(). Проверяем оба пути в
|
||
`scraper_kit.base` (единственный живой модуль — `app.services.scrapers.base`
|
||
удалён #2397 финальный шаг E, mirror-тесты через legacy убраны) и свойства
|
||
ретро-бэкфилл-миграции 161.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import psycopg.errors
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from scraper_kit.base import ScrapedLot as KitLot
|
||
from scraper_kit.base import save_listings as kit_save_listings
|
||
|
||
|
||
@contextmanager
|
||
def _nested_ctx() -> Any:
|
||
yield MagicMock()
|
||
|
||
|
||
def _mock_db_update_path(inserted: bool = False, listing_id: int = 42) -> MagicMock:
|
||
"""Session mock для ON CONFLICT DO UPDATE (xmax != 0 → updated)."""
|
||
insert_row = MagicMock()
|
||
insert_row.id = listing_id
|
||
insert_row.inserted = inserted
|
||
|
||
db = MagicMock()
|
||
|
||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||
s = str(sql)
|
||
res = MagicMock()
|
||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||
res.fetchone.return_value = None
|
||
elif "FROM listings_snapshots" in s:
|
||
res.fetchone.return_value = None
|
||
elif "INSERT INTO listings (" in s:
|
||
res.fetchone.return_value = insert_row
|
||
else:
|
||
res.fetchone.return_value = None
|
||
return res
|
||
|
||
db.execute.side_effect = _execute
|
||
db.begin_nested.side_effect = _nested_ctx
|
||
return db
|
||
|
||
|
||
def _mock_db_reconcile(reconcile_id: int = 77) -> MagicMock:
|
||
"""Session mock: INSERT → UniqueViolation → reconcile UPDATE by (source, source_id)."""
|
||
uv_orig = psycopg.errors.UniqueViolation()
|
||
integrity_err = IntegrityError("INSERT INTO listings ...", {}, uv_orig)
|
||
|
||
rec_row = MagicMock()
|
||
rec_row.id = reconcile_id
|
||
|
||
db = MagicMock()
|
||
|
||
def _execute(sql: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||
s = str(sql)
|
||
res = MagicMock()
|
||
if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
|
||
res.fetchone.return_value = None
|
||
elif "FROM listings_snapshots" in s:
|
||
res.fetchone.return_value = None
|
||
elif "INSERT INTO listings (" in s:
|
||
raise integrity_err
|
||
elif "UPDATE listings" in s and "SET dedup_hash" in s:
|
||
res.fetchone.return_value = rec_row
|
||
else:
|
||
res.fetchone.return_value = None
|
||
return res
|
||
|
||
db.execute.side_effect = _execute
|
||
|
||
@contextmanager
|
||
def _nested() -> Any:
|
||
try:
|
||
yield MagicMock()
|
||
except IntegrityError:
|
||
raise
|
||
|
||
db.begin_nested.side_effect = _nested
|
||
return db
|
||
|
||
|
||
def _find_sql(db: MagicMock, needle: str) -> str:
|
||
for call in db.execute.call_args_list:
|
||
sql = str(call.args[0])
|
||
if needle in sql:
|
||
return sql
|
||
raise AssertionError(f"SQL containing {needle!r} not found")
|
||
|
||
|
||
# ── scraper_kit.base (единственный живой путь — legacy app.services.scrapers.base
|
||
# удалён #2397 финальный шаг E) ─────────────────────────────────────────────────
|
||
|
||
|
||
def _kit_matcher() -> MagicMock:
|
||
matcher = MagicMock()
|
||
matcher.match_or_create_house.return_value = (101, 1.0, "new")
|
||
matcher.upsert_listing_source.return_value = None
|
||
return matcher
|
||
|
||
|
||
def test_kit_on_conflict_bumps_scraped_at() -> None:
|
||
"""kit ON CONFLICT DO UPDATE двигает scraped_at = NOW()."""
|
||
db = _mock_db_update_path(inserted=False)
|
||
lot = KitLot(
|
||
source="cian",
|
||
source_url="https://ekb.cian.ru/sale/flat/1/",
|
||
source_id="1",
|
||
price_rub=5_000_000,
|
||
)
|
||
|
||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||
inserted, updated = kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||
|
||
assert (inserted, updated) == (0, 1)
|
||
sql = _find_sql(db, "INSERT INTO listings (")
|
||
assert "scraped_at = NOW()" in sql
|
||
assert "last_seen_at = NOW()" in sql
|
||
|
||
|
||
def test_kit_reconcile_bumps_scraped_at() -> None:
|
||
"""kit dedup-drift reconcile UPDATE двигает scraped_at = NOW()."""
|
||
db = _mock_db_reconcile(reconcile_id=88)
|
||
lot = KitLot(
|
||
source="avito",
|
||
source_url="https://www.avito.ru/ekaterinburg/kvartiry/1-k_1",
|
||
source_id="7960764619",
|
||
price_rub=5_000_000,
|
||
)
|
||
|
||
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
|
||
kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66)
|
||
|
||
sql = _find_sql(db, "SET dedup_hash")
|
||
assert "scraped_at = NOW()" in sql
|
||
assert "last_seen_at = NOW()" in sql
|
||
|
||
|
||
# ── Migration 161: retro-backfill scraped_at ──────────────────────────────────
|
||
|
||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||
_MIGRATION_161 = _SQL_DIR / "161_backfill_scraped_at_active_recent.sql"
|
||
|
||
|
||
def test_migration_161_exists() -> None:
|
||
assert _MIGRATION_161.is_file(), f"missing migration: {_MIGRATION_161}"
|
||
|
||
|
||
def test_migration_161_is_transactional() -> None:
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert "BEGIN;" in sql
|
||
assert "COMMIT;" in sql
|
||
|
||
|
||
def test_migration_161_sets_scraped_at_from_last_seen_at() -> None:
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert "SET scraped_at = last_seen_at" in sql
|
||
|
||
|
||
def test_migration_161_is_idempotent_by_construction() -> None:
|
||
"""Идемпотентность: WHERE last_seen_at > scraped_at → после первого прогона 0 строк."""
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert "last_seen_at > scraped_at" in sql
|
||
|
||
|
||
def test_migration_161_only_active_rows() -> None:
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert "is_active" in sql
|
||
|
||
|
||
def test_migration_161_scoped_to_scraped_sources() -> None:
|
||
"""avito/cian/yandex включены; domklik/n1 — НЕТ (поломанный bulk-touch last_seen_at)."""
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert "IN ('avito', 'cian', 'yandex')" in sql
|
||
|
||
|
||
def test_migration_161_excludes_domklik() -> None:
|
||
"""domklik намеренно исключён: last_seen_at bulk-touch'ится ежедневно → ложное освежение."""
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
# domklik не в списке источников UPDATE (упоминание только в комментарии-обосновании)
|
||
assert "'domklik'" not in sql
|
||
assert "domklik" in sql # обоснование исключения зафиксировано в комментарии
|
||
|
||
|
||
def test_migration_161_no_psycopg_trap() -> None:
|
||
"""psycopg v3: никаких :param::type."""
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert not re.search(r":\w+::", sql)
|
||
|
||
|
||
def test_migration_161_non_destructive_update_only() -> None:
|
||
sql = _MIGRATION_161.read_text("utf-8")
|
||
assert "UPDATE listings" in sql
|
||
assert "DELETE" not in sql.upper()
|