gendesign/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py
bot-backend cdf493f345
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
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

258 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Повторный скрейп неизменившегося объявления не переписывает строку (#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)