"""#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()