"""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.short_address or h.full_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., 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