All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
258 lines
12 KiB
Python
258 lines
12 KiB
Python
"""Повторный скрейп неизменившегося объявления не переписывает строку (#2992).
|
||
|
||
Апсерт listings безусловно присваивал 41 колонку при каждом повторном скрейпе —
|
||
заново тостил description и плодил TOAST-чанки. Прод 2026-08-20: n_tup_upd
|
||
20.87 млн на 107 тыс. строк (198 апдейтов на строку за 91 день), HOT 0.44 %,
|
||
TOAST 15 ГБ при ~230 МБ полезного, WAL 7 ГБ/сутки.
|
||
|
||
Гейт в `ON CONFLICT DO UPDATE … WHERE`: апдейт идёт, если итоговое
|
||
(post-COALESCE) значение хоть одной контентной колонки отличается ИЛИ
|
||
last_seen_at ещё не сегодняшний по МСК. Второе условие обязательно: метка
|
||
живости должна сдвигаться хотя бы раз в сутки — на неё завязаны деактиватор,
|
||
снапшоты, эстиматор (#2206), монитор свежести.
|
||
|
||
Проверяется на ЖИВОМ Postgres через реальный save_listings, в транзакции с
|
||
откатом. Счётчик апдейтов — pg_stat_xact_user_tables (текущая транзакция),
|
||
то есть ровно «повторный скрейп не увеличивает n_tup_upd» из acceptance.
|
||
Без БД — skip (как соседние live-тесты; в CI Trade-In есть postgres-сервис).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import uuid
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from scraper_kit.base import ScrapedLot, save_listings
|
||
from sqlalchemy import text
|
||
|
||
|
||
def _live_session() -> Any | None:
|
||
try:
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||
if not dsn or "localhost:5432/test" in dsn:
|
||
return None
|
||
engine = create_engine(dsn, future=True)
|
||
conn = engine.connect()
|
||
conn.execute(text("SELECT 1"))
|
||
conn.close()
|
||
return sessionmaker(bind=engine, future=True)()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _matcher() -> MagicMock:
|
||
m = MagicMock()
|
||
m.match_or_create_house.return_value = (None, 0.0, "no_address")
|
||
m.upsert_listing_source.return_value = None
|
||
return m
|
||
|
||
|
||
def _lot(
|
||
src_id: str,
|
||
price: int = 5_000_000,
|
||
description: str | None = "тестовое описание",
|
||
address: str | None = None,
|
||
) -> ScrapedLot:
|
||
return ScrapedLot(
|
||
source="cian",
|
||
source_url=f"https://ekb.cian.ru/sale/flat/t2992-{src_id}/",
|
||
source_id=f"t2992-{src_id}",
|
||
price_rub=price,
|
||
description=description,
|
||
address=address,
|
||
)
|
||
|
||
|
||
def _row(db: Any, src_id: str) -> Any:
|
||
"""Строка + её физическая версия (ctid). save_listings КОММИТИТ, поэтому
|
||
транзакционные счётчики pg_stat_xact_* тут бесполезны (сбрасываются). UPDATE
|
||
всегда создаёт новую версию строки → ctid меняется; пропущенный апдейт → ctid
|
||
тот же. Это и есть прямой замер «переписана ли строка»."""
|
||
return db.execute(
|
||
text(
|
||
"SELECT id, ctid::text AS ctid, price_rub, city, description, last_seen_at "
|
||
"FROM listings WHERE source = 'cian' AND source_id = :sid"
|
||
),
|
||
{"sid": f"t2992-{src_id}"},
|
||
).fetchone()
|
||
|
||
|
||
def _cleanup(db: Any) -> None:
|
||
"""save_listings коммитит — rollback не спасает. Убираем свои строки явно."""
|
||
try:
|
||
db.rollback()
|
||
db.execute(
|
||
text(
|
||
"DELETE FROM listings_snapshots WHERE listing_id IN "
|
||
"(SELECT id FROM listings WHERE source='cian' AND source_id LIKE 't2992-%')"
|
||
)
|
||
)
|
||
db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't2992-%'"))
|
||
db.execute(text("DELETE FROM listings WHERE source='cian' AND source_id LIKE 't2992-%'"))
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||
def test_unchanged_rescrape_same_day_does_not_update_the_row() -> None:
|
||
"""Головной: второй апсерт неизменного лота в тот же МСК-день — n_tup_upd не растёт.
|
||
|
||
На origin/main (без гейта) второй save_listings переписывает строку — счётчик +1.
|
||
"""
|
||
db = _live_session()
|
||
sid = uuid.uuid4().hex[:8]
|
||
try:
|
||
save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
|
||
first = _row(db, sid)
|
||
assert first is not None, "первая вставка не прошла"
|
||
ins, upd = save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
|
||
second = _row(db, sid)
|
||
assert second.ctid == first.ctid, (
|
||
f"повторный скрейп неизменного лота переписал строку: ctid {first.ctid}→{second.ctid}"
|
||
)
|
||
assert (ins, upd) == (0, 0), f"счётчики: inserted={ins} updated={upd}, ждали 0/0"
|
||
assert second.last_seen_at == first.last_seen_at, "last_seen_at сдвинулся без нужды"
|
||
finally:
|
||
_cleanup(db)
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||
def test_changed_price_still_updates() -> None:
|
||
"""Контроль: изменение контента проходит — гейт не глушит настоящие изменения."""
|
||
db = _live_session()
|
||
sid = uuid.uuid4().hex[:8]
|
||
try:
|
||
save_listings(db, [_lot(sid, price=5_000_000)], matcher=_matcher(), region_code=66)
|
||
first = _row(db, sid)
|
||
save_listings(db, [_lot(sid, price=5_100_000)], matcher=_matcher(), region_code=66)
|
||
second = _row(db, sid)
|
||
assert second.ctid != first.ctid, "изменение цены не записалось (строка не переписана)"
|
||
assert second.price_rub == 5_100_000
|
||
finally:
|
||
_cleanup(db)
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||
def test_coalesce_backfill_still_updates() -> None:
|
||
"""Контроль: COALESCE-дозаполнение (город NULL → значение) проходит.
|
||
|
||
Именно за это забраковали гейт по card_hash: он бы блокировал дозаполнение.
|
||
Сравнение идёт по ИТОГОВЫМ post-COALESCE значениям — если дописал, строка
|
||
отличается.
|
||
"""
|
||
db = _live_session()
|
||
sid = uuid.uuid4().hex[:8]
|
||
try:
|
||
# city приходит ПАРАМЕТРОМ save_listings (развёртка города), не полем лота.
|
||
save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city=None)
|
||
first = _row(db, sid)
|
||
assert first.city is None
|
||
save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city="Екатеринбург")
|
||
second = _row(db, sid)
|
||
assert second.ctid != first.ctid, "дозаполнение города заблокировано гейтом"
|
||
assert second.city == "Екатеринбург"
|
||
finally:
|
||
_cleanup(db)
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||
def test_next_day_rescrape_updates_even_if_unchanged() -> None:
|
||
"""Живость: если last_seen_at вчерашний — апдейт идёт и без изменений контента.
|
||
|
||
Иначе деактиватор/снапшоты/эстиматор потеряли бы живой инвентарь. Симулируем
|
||
«вчера», отодвинув last_seen_at у только что вставленной строки.
|
||
"""
|
||
db = _live_session()
|
||
sid = uuid.uuid4().hex[:8]
|
||
try:
|
||
save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
|
||
db.execute(
|
||
text(
|
||
"UPDATE listings SET last_seen_at = last_seen_at - interval '1 day' "
|
||
"WHERE source='cian' AND source_id = :sid"
|
||
),
|
||
{"sid": f"t2992-{sid}"},
|
||
)
|
||
db.commit()
|
||
first = _row(db, sid)
|
||
save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
|
||
second = _row(db, sid)
|
||
assert second.ctid != first.ctid, "суточное ре-подтверждение живости заблокировано"
|
||
assert second.last_seen_at > first.last_seen_at, "last_seen_at не сдвинулся"
|
||
finally:
|
||
_cleanup(db)
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||
def test_skipped_row_still_yields_listing_id_for_downstream() -> None:
|
||
"""При пропуске апдейта listing_id всё равно известен — downstream (снапшот,
|
||
матчинг, listing_sources) идёт; иначе гейт молча отключил бы сопоставление."""
|
||
db = _live_session()
|
||
sid = uuid.uuid4().hex[:8]
|
||
m = _matcher()
|
||
lot = _lot(sid, address="Екатеринбург, ул. Тестовая, 1") # матчинг идёт только с адресом/гео
|
||
try:
|
||
save_listings(db, [lot], matcher=m, region_code=66)
|
||
calls_before = m.match_or_create_house.call_count
|
||
assert calls_before == 1, (
|
||
"контроль сконструирован неверно: матчинг не зовётся и в первый раз"
|
||
)
|
||
save_listings(db, [lot], matcher=m, region_code=66)
|
||
assert m.match_or_create_house.call_count == calls_before + 1, (
|
||
"при пропущенном апдейте матчинг не вызван — listing_id потерян"
|
||
)
|
||
finally:
|
||
_cleanup(db)
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||
def test_listing_sources_unchanged_rescrape_same_day_does_not_update() -> None:
|
||
"""Симметричный гейт на listing_sources — второй по нагрузке таблице (10.27 млн
|
||
апдейтов, HOT 0.10 %). Повторный upsert_listing_source того же лота в тот же
|
||
МСК-день не переписывает строку; изменение цены — переписывает."""
|
||
from app.services.matching.listings import upsert_listing_source
|
||
|
||
db = _live_session()
|
||
sid = uuid.uuid4().hex[:8]
|
||
try:
|
||
save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
|
||
lid = int(_row(db, sid).id)
|
||
|
||
def ls_row() -> Any:
|
||
return db.execute(
|
||
text(
|
||
"SELECT ctid::text AS ctid, price_rub, last_seen_at FROM listing_sources "
|
||
"WHERE ext_source='cian' AND ext_id = :e"
|
||
),
|
||
{"e": f"t2992-{sid}"},
|
||
).fetchone()
|
||
|
||
kw = dict(ext_source="cian", ext_id=f"t2992-{sid}", method="test", confidence=0.9)
|
||
upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw)
|
||
db.commit()
|
||
first = ls_row()
|
||
assert first is not None
|
||
upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw)
|
||
db.commit()
|
||
second = ls_row()
|
||
assert second.ctid == first.ctid, (
|
||
f"listing_sources переписана без изменений: ctid {first.ctid}→{second.ctid}"
|
||
)
|
||
upsert_listing_source(db, listing_id=lid, price_rub=5_100_000, **kw)
|
||
db.commit()
|
||
third = ls_row()
|
||
assert third.ctid != second.ctid and third.price_rub == 5_100_000, (
|
||
"изменение цены не записалось"
|
||
)
|
||
finally:
|
||
_cleanup(db)
|