gendesign/tradein-mvp/backend/tests/test_3063_seller_fields_not_eroded.py
lekss361 e73cde7ad3
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
fix(scraper-kit): бедный re-scrape больше не стирает признаки продавца (#3067)
2026-08-23 21:52:38 +00:00

244 lines
11 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.

"""#3063: бедный re-scrape больше не стирает признаки продавца.
Апсерт listings присваивал шесть полей напрямую (`= EXCLUDED.X`):
phones, is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations.
Комментарий рядом («Cian-specific: обновляем при каждом re-scrape») был верен,
пока в listings писал один cian, который отдаёт их на каждом проходе. Сейчас в ту
же таблицу пишут yandex/avito/domklik, чей SERP их не отдаёт вовсе — и NULL молча
затирал значение, добытое detail-обогащением. Ни ошибки, ни лога, ни изменения
статуса прогона: колонка просто откатывалась к NULL на обычном переобходе.
Прод 23-24.08: у yandex is_homeowner NULL у 98.6 % (16 232 из 16 460) активных
листингов, is_pro_seller — у 76.6 %.
Соседние поля (address, city, kitchen_area_m2, ceiling_height_m,
mortgage_available, is_apartments, is_rosreestr_checked) уже защищены COALESCE
ровно от этого — с явными объяснениями #2007/#2594/#2777. Эти шесть в защиту
просто не попали.
"""
from __future__ import annotations
import os
import re
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import inspect
import uuid
from typing import Any
from unittest.mock import MagicMock
import pytest
import scraper_kit.base as base_module
from scraper_kit.base import ScrapedLot, save_listings
from sqlalchemy import text
# Поля, которые detail добывает, а SERP чужого источника не отдаёт.
ERODED_FIELDS = [
"phones",
"is_homeowner",
"is_pro_seller",
"bargain_allowed",
"sale_type",
"metro_stations",
]
def _upsert_sql() -> str:
return inspect.getsource(base_module)
# ── 1. Статические гарды: работают всегда, даже без БД ───────────────────────
@pytest.mark.parametrize("field", ERODED_FIELDS)
def test_field_is_coalesce_protected_in_set(field: str) -> None:
"""SET присваивает поле через COALESCE, а не сырым EXCLUDED.
Фальсификация: на коде до правки каждый кейс падает — там стоит
`is_homeowner = EXCLUDED.is_homeowner` без COALESCE.
"""
src = _upsert_sql()
assert not re.search(rf"^\s*{field} = EXCLUDED\.{field},", src, re.M), (
f"{field} присваивается сырым EXCLUDED — бедный re-scrape затрёт значение"
)
assert re.search(
rf"{field} = COALESCE\(\s*EXCLUDED\.{field}, listings\.{field}\s*\)", src
), f"{field} должен присваиваться через COALESCE(EXCLUDED.{field}, listings.{field})"
def test_unchanged_gate_mirrors_the_set_clause() -> None:
"""Правая часть `IS DISTINCT FROM` зеркалит то, что реально запишется.
Это НЕ стилистика. Гейт #2992 решает, переписывать ли строку, сравнивая
текущие значения с ИТОГОВЫМИ (post-COALESCE). Если SET станет COALESCE, а
гейт останется на сыром EXCLUDED, они разъедутся: гейт увидит «NULL против
значения» и посчитает строку изменившейся там, где она не меняется —
вернутся ровно те лишние UPDATE и TOAST-чанки, ради которых #2992 делался.
Гард структурный: сравниваем два кортежа поэлементно, поэтому он поймает и
будущее расхождение по ЛЮБОЙ колонке, не только по шести из #3063.
"""
src = _upsert_sql()
m = re.search(
r"WHERE \(\n(.*?)\n\s*\) IS DISTINCT FROM \(\n(.*?)\n\s*\)\n\s*OR \(listings\.last_seen_at",
src,
re.S,
)
assert m is not None, "не найден гейт `IS DISTINCT FROM` — тест устарел вместе с кодом"
def items(block: str) -> list[str]:
block = re.sub(r"--[^\n]*", "", block) # комментарии внутри кортежа
out: list[str] = []
depth, cur = 0, ""
for ch in block:
if ch == "(":
depth += 1
if ch == ")":
depth -= 1
if ch == "," and depth == 0:
out.append(cur.strip())
cur = ""
else:
cur += ch
if cur.strip():
out.append(cur.strip())
return [" ".join(x.split()) for x in out]
left, right = items(m.group(1)), items(m.group(2))
assert len(left) == len(right), (
f"кортежи гейта разной длины: слева {len(left)}, справа {len(right)}"
"сравнение поехало бы по колонкам молча"
)
# strict=True безопасен: равенство длин уже проверено assert выше.
for i, (lhs, rhs) in enumerate(zip(left, right, strict=True)):
column = lhs.split(".")[-1]
if column == "is_active":
continue # SET пишет литерал true, справа он же — намеренно
assert column in rhs, f"позиция {i}: слева {lhs}, справа {rhs} — колонки разъехались"
if column in ERODED_FIELDS:
assert rhs.startswith("COALESCE("), (
f"{column} в гейте сравнивается сырым EXCLUDED, а SET пишет COALESCE"
)
# ── 2. Поведенческий тест на живом Postgres (в CI есть, локально skip) ───────
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, *, rich: bool) -> ScrapedLot:
"""rich=True — как detail-обогащение; rich=False — как бедный SERP чужого
источника (поля не заполнены вовсе)."""
extra: dict[str, Any] = {}
if rich:
extra = {
"is_homeowner": True,
"is_pro_seller": False,
# phones — jsonb вида [{countryCode, number, type}, ...]
# (019_listings_alter_cian.sql:41), а не список строк.
"phones": [{"countryCode": "+7", "number": "9000000000", "type": "mobile"}],
"sale_type": "free",
}
return ScrapedLot(
source="cian",
source_url=f"https://ekb.cian.ru/sale/flat/t3063-{src_id}/",
source_id=f"t3063-{src_id}",
price_rub=5_000_000,
**extra,
)
def _cleanup(db: Any) -> None:
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 't3063-%')"
)
)
db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't3063-%'"))
db.execute(text("DELETE FROM listings WHERE source='cian' AND source_id LIKE 't3063-%'"))
db.commit()
finally:
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_poor_rescrape_does_not_erase_seller_fields() -> None:
"""Головной: обогащённая строка переживает последующий бедный проход.
На коде до правки второй save_listings обнуляет is_homeowner/phones/sale_type.
"""
db = _live_session()
sid = uuid.uuid4().hex[:8]
try:
save_listings(db, [_lot(sid, rich=True)], matcher=_matcher(), region_code=66)
save_listings(db, [_lot(sid, rich=False)], matcher=_matcher(), region_code=66)
row = db.execute(
text(
"SELECT is_homeowner, is_pro_seller, phones, sale_type FROM listings "
"WHERE source='cian' AND source_id = :sid"
),
{"sid": f"t3063-{sid}"},
).fetchone()
assert row is not None, "строка исчезла"
assert row.is_homeowner is True, "бедный re-scrape стёр is_homeowner"
assert row.is_pro_seller is False, "бедный re-scrape стёр is_pro_seller (False -> NULL)"
assert row.phones is not None, "бедный re-scrape стёр phones"
assert row.sale_type == "free", "бедный re-scrape стёр sale_type"
finally:
_cleanup(db)
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_change_still_overwrites() -> None:
"""Обратная сторона: COALESCE не превращает поля в write-once.
Настоящая смена признака (частник -> агентство) приходит НЕ NULL'ом, значит
обязана перезаписать. Без этого теста фикс мог бы «защитить» данные ценой
того, что они перестали бы обновляться вообще.
"""
db = _live_session()
sid = uuid.uuid4().hex[:8]
try:
save_listings(db, [_lot(sid, rich=True)], matcher=_matcher(), region_code=66)
flipped = _lot(sid, rich=True)
flipped.is_homeowner = False
flipped.is_pro_seller = True
save_listings(db, [flipped], matcher=_matcher(), region_code=66)
row = db.execute(
text(
"SELECT is_homeowner, is_pro_seller FROM listings "
"WHERE source='cian' AND source_id = :sid"
),
{"sid": f"t3063-{sid}"},
).fetchone()
assert row.is_homeowner is False, "настоящая смена признака не записалась"
assert row.is_pro_seller is True, "настоящая смена признака не записалась"
finally:
_cleanup(db)