Корень проблемы: avito_houses.py обходил канонический matching-путь через прямой INSERT ON CONFLICT(source, ext_house_id), создавая дубли для физически одного дома (~33% таблицы houses = ~4479 лишних строк). Forward fix: upsert_house/upsert_house_with_url → _persist_house(db, h, url), который вызывает match_or_create_house (3-tier: source_exact→fingerprint→geo→new, с pg_advisory_xact_lock) и затем UPDATE с enrichment-полями. avito-авторитетные поля (rating, developer, технические хар-ки) перезаписываются напрямую; address/lat/lon/year_built — через COALESCE чтобы не затирать canonical-данные. Merge-миграция 108: cluster_key по cadastral/address/geo → canonical (max не-NULL полей, geom присутствует, min(id)) → репойнт всех 11 зависимых таблиц с обработкой UNIQUE-коллизий → DELETE дублей → backfill house_sources/aliases.
271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""Tests for avito_houses dedup fix — _persist_house делегирует в match_or_create_house.
|
||
|
||
Проверяет:
|
||
1. Два HouseInfo с разными ext_id но одинаковым адресом и координатами
|
||
→ _persist_house возвращает ОДИН house_id (не плодит дубль).
|
||
2. Enrichment-поля (rating_score, developer_name) реально записываются в UPDATE.
|
||
3. _persist_house не вызывает db.commit().
|
||
4. upsert_house и _upsert_house_with_url делегируют в _persist_house.
|
||
|
||
Использует тот же паттерн mock-DB что в test_matching.py и test_same_building_match.py.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from app.services.scrapers.avito_houses import (
|
||
HouseInfo,
|
||
_persist_house,
|
||
_upsert_house_with_url,
|
||
upsert_house,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_house(ext_id: int = 111, address: str = "ул. Ленина, 5") -> HouseInfo:
|
||
return HouseInfo(
|
||
ext_id=ext_id,
|
||
ext_id_hash="hash_" + str(ext_id),
|
||
short_address=address,
|
||
full_address="Свердловская обл., Екатеринбург, " + address,
|
||
lat=56.8378,
|
||
lon=60.5946,
|
||
year_built=2010,
|
||
house_type="monolith",
|
||
total_floors=16,
|
||
passenger_elevators=2,
|
||
cargo_elevators=1,
|
||
has_concierge=True,
|
||
closed_yard=False,
|
||
material_floors="concrete",
|
||
hot_water="central",
|
||
parking_type="underground",
|
||
has_playground=True,
|
||
developer_name="ПИК",
|
||
developer_key="pik",
|
||
infrastructure_summary="Школа, детсад",
|
||
infrastructure_walk_distance="5 мин",
|
||
rating_score=4.5,
|
||
rating_string="4.5 из 5",
|
||
reviews_count=120,
|
||
rating_distribution=[{"score": 5, "count": 80}],
|
||
map_pins=[{"type": "metro", "name": "Уральская"}],
|
||
raw_characteristics=[{"title": "Год постройки", "value": "2010"}],
|
||
)
|
||
|
||
|
||
def _make_db_for_persist(canonical_id: int = 42) -> MagicMock:
|
||
"""Mock DB для _persist_house: match_or_create_house ходит через db.execute,
|
||
поэтому патчим match_or_create_house целиком и проверяем вызов UPDATE.
|
||
"""
|
||
db = MagicMock()
|
||
# db.execute для UPDATE houses SET ... — просто возвращаем None (rowcount не важен)
|
||
db.execute.return_value = MagicMock()
|
||
return db
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 1: два HouseInfo с разными ext_id, одинаковый адрес → один house_id
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_persist_house_same_address_returns_same_id():
|
||
"""Два HouseInfo с разными ext_id но одинаковым адресом → один canonical house_id.
|
||
|
||
Моделируем случай: первый вызов (ext_id=111) → match_or_create_house вернул 42 (new).
|
||
Второй вызов (ext_id=222, тот же адрес) → match_or_create_house вернул 42 (fingerprint).
|
||
Оба _persist_house должны вернуть 42.
|
||
"""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
|
||
h1 = _make_house(ext_id=111)
|
||
h2 = _make_house(ext_id=222) # другой ext_id, тот же адрес/координаты
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
side_effect=[(42, 1.0, "new"), (42, 0.9, "fingerprint")],
|
||
) as mock_match:
|
||
id1 = _persist_house(db, h1, "https://avito.ru/house/111")
|
||
id2 = _persist_house(db, h2, "https://avito.ru/house/222")
|
||
|
||
assert id1 == 42, f"Первый вызов должен вернуть 42, получили {id1}"
|
||
assert id2 == 42, f"Второй вызов должен вернуть тот же 42, получили {id2}"
|
||
assert id1 == id2, "Оба вызова должны вернуть один house_id (нет дубля)"
|
||
assert mock_match.call_count == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 2: enrichment-поля записываются в UPDATE
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_persist_house_enrichment_fields_written():
|
||
"""Enrichment-поля (rating_score, developer_name) передаются в UPDATE-запрос."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
|
||
h = _make_house(ext_id=111)
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
return_value=(42, 1.0, "source_exact"),
|
||
):
|
||
_persist_house(db, h, "https://avito.ru/house/111")
|
||
|
||
# db.execute должен быть вызван 1 раз (UPDATE houses SET ...)
|
||
assert (
|
||
db.execute.call_count == 1
|
||
), f"Ожидался 1 вызов db.execute (UPDATE), получили {db.execute.call_count}"
|
||
|
||
# Проверяем параметры bind
|
||
call_args = db.execute.call_args
|
||
sql_obj = call_args[0][0]
|
||
bind = call_args[0][1]
|
||
|
||
assert "UPDATE houses" in str(sql_obj), "SQL должен содержать UPDATE houses"
|
||
assert bind["rating_score"] == 4.5, f"rating_score={bind.get('rating_score')!r}"
|
||
assert bind["developer_name"] == "ПИК", f"developer_name={bind.get('developer_name')!r}"
|
||
assert bind["house_id"] == 42, f"house_id={bind.get('house_id')!r}"
|
||
|
||
# avito_id_hash должен быть передан
|
||
assert bind["avito_id_hash"] == "hash_111", f"avito_id_hash={bind.get('avito_id_hash')!r}"
|
||
|
||
# rating_distribution должен быть JSON-строкой
|
||
rd = bind.get("rating_distribution")
|
||
assert isinstance(rd, str), f"rating_distribution должен быть str (JSON), получили {type(rd)}"
|
||
assert json.loads(rd) == [{"score": 5, "count": 80}]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 3: _persist_house не вызывает db.commit()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_persist_house_no_commit():
|
||
"""_persist_house не должен вызывать db.commit() — коммитит вызывающий."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
|
||
h = _make_house()
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
return_value=(7, 1.0, "new"),
|
||
):
|
||
_persist_house(db, h, "")
|
||
|
||
db.commit.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 4: upsert_house делегирует в _persist_house (с пустым url)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_upsert_house_delegates_to_persist_house():
|
||
"""upsert_house(db, h) → _persist_house(db, h, "") → возвращает house_id."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
h = _make_house()
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
return_value=(55, 1.0, "new"),
|
||
):
|
||
result = upsert_house(db, h)
|
||
|
||
assert result == 55
|
||
|
||
# Проверяем что UPDATE был вызван с url="" (пустая строка — нет url в этом пути)
|
||
bind = db.execute.call_args[0][1]
|
||
assert bind["url"] == "", f"url должен быть пустой строкой, получили {bind.get('url')!r}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 5: _upsert_house_with_url делегирует в _persist_house (с реальным url)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_upsert_house_with_url_delegates_to_persist_house():
|
||
"""_upsert_house_with_url(db, h, url) → _persist_house(db, h, url) → house_id."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
h = _make_house()
|
||
url = "https://www.avito.ru/catalog/houses/ekb/dom-1234"
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
return_value=(99, 1.0, "new"),
|
||
):
|
||
result = _upsert_house_with_url(db, h, url)
|
||
|
||
assert result == 99
|
||
|
||
# Проверяем что правильный url передан в UPDATE
|
||
bind = db.execute.call_args[0][1]
|
||
assert bind["url"] == url, f"url должен быть {url!r}, получили {bind.get('url')!r}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 6: match_or_create_house вызывается с правильными аргументами
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_persist_house_calls_match_with_correct_args():
|
||
"""_persist_house передаёт правильные аргументы в match_or_create_house."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
|
||
h = _make_house(ext_id=333)
|
||
url = "https://avito.ru/333"
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
return_value=(10, 1.0, "new"),
|
||
) as mock_match:
|
||
_persist_house(db, h, url)
|
||
|
||
mock_match.assert_called_once_with(
|
||
db,
|
||
ext_source="avito",
|
||
ext_id="333",
|
||
address=h.full_address or h.short_address,
|
||
lat=h.lat,
|
||
lon=h.lon,
|
||
year_built=h.year_built,
|
||
source_url=url,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Test 7: COALESCE — адрес/координаты не затираются через UPDATE параметры
|
||
# (проверяем что bind содержит address/lat/lon для COALESCE в SQL)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_persist_house_coalesce_fields_in_bind():
|
||
"""UPDATE передаёт address/lat/lon в bind для COALESCE(houses.<col>, CAST(:x AS ...))."""
|
||
db = MagicMock()
|
||
db.execute.return_value = MagicMock()
|
||
|
||
h = _make_house()
|
||
|
||
with patch(
|
||
"app.services.scrapers.avito_houses.match_or_create_house",
|
||
return_value=(1, 1.0, "new"),
|
||
):
|
||
_persist_house(db, h, "")
|
||
|
||
bind = db.execute.call_args[0][1]
|
||
assert bind["address"] == h.short_address
|
||
assert bind["lat"] == h.lat
|
||
assert bind["lon"] == h.lon
|
||
assert bind["year_built"] == h.year_built
|