gendesign/tradein-mvp/backend/tests/test_avito_houses_dedup.py
bot-backend b72af1cc49
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
fix(tradein): avito houses dedup через match_or_create_house + merge-миграция дублей (108)
Корень проблемы: 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.
2026-06-15 22:40:46 +03:00

271 lines
11 KiB
Python
Raw 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.

"""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