feat(tradein): extend base.py save_listings to persist Cian-specific columns (PR #450 follow-up) #455
2 changed files with 297 additions and 1 deletions
|
|
@ -223,6 +223,10 @@ def save_listings(db: Session, lots: list[ScrapedLot]) -> tuple[int, int]:
|
||||||
house_source, house_ext_id, house_url, listing_segment,
|
house_source, house_ext_id, house_url, listing_segment,
|
||||||
price_rub, price_per_m2,
|
price_rub, price_per_m2,
|
||||||
listing_date, days_on_market, photo_urls, raw_payload,
|
listing_date, days_on_market, photo_urls, raw_payload,
|
||||||
|
living_area_m2, bedrooms_count, balconies_count, loggias_count,
|
||||||
|
description_minhash, cadastral_number, building_cadastral_number,
|
||||||
|
phones, is_homeowner, is_pro_seller,
|
||||||
|
bargain_allowed, sale_type, metro_stations,
|
||||||
scraped_at, last_seen_at
|
scraped_at, last_seen_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:source, :source_url, :source_id, :dedup,
|
:source, :source_url, :source_id, :dedup,
|
||||||
|
|
@ -234,6 +238,10 @@ def save_listings(db: Session, lots: list[ScrapedLot]) -> tuple[int, int]:
|
||||||
:listing_date, :days_on_market,
|
:listing_date, :days_on_market,
|
||||||
CAST(:photos AS jsonb),
|
CAST(:photos AS jsonb),
|
||||||
CAST(:raw AS jsonb),
|
CAST(:raw AS jsonb),
|
||||||
|
:living_area_m2, :bedrooms_count, :balconies_count, :loggias_count,
|
||||||
|
:description_minhash, :cadastral_number, :building_cadastral_number,
|
||||||
|
CAST(:phones AS jsonb), :is_homeowner, :is_pro_seller,
|
||||||
|
:bargain_allowed, :sale_type, CAST(:metro_stations AS jsonb),
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (dedup_hash) DO UPDATE
|
ON CONFLICT (dedup_hash) DO UPDATE
|
||||||
|
|
@ -241,7 +249,21 @@ def save_listings(db: Session, lots: list[ScrapedLot]) -> tuple[int, int]:
|
||||||
is_active = true,
|
is_active = true,
|
||||||
-- если цена изменилась — обновляем
|
-- если цена изменилась — обновляем
|
||||||
price_rub = EXCLUDED.price_rub,
|
price_rub = EXCLUDED.price_rub,
|
||||||
price_per_m2 = EXCLUDED.price_per_m2
|
price_per_m2 = EXCLUDED.price_per_m2,
|
||||||
|
-- Cian-specific: обновляем при каждом re-scrape
|
||||||
|
living_area_m2 = EXCLUDED.living_area_m2,
|
||||||
|
bedrooms_count = EXCLUDED.bedrooms_count,
|
||||||
|
balconies_count = EXCLUDED.balconies_count,
|
||||||
|
loggias_count = EXCLUDED.loggias_count,
|
||||||
|
description_minhash = EXCLUDED.description_minhash,
|
||||||
|
cadastral_number = EXCLUDED.cadastral_number,
|
||||||
|
building_cadastral_number = EXCLUDED.building_cadastral_number,
|
||||||
|
phones = EXCLUDED.phones,
|
||||||
|
is_homeowner = EXCLUDED.is_homeowner,
|
||||||
|
is_pro_seller = EXCLUDED.is_pro_seller,
|
||||||
|
bargain_allowed = EXCLUDED.bargain_allowed,
|
||||||
|
sale_type = EXCLUDED.sale_type,
|
||||||
|
metro_stations = EXCLUDED.metro_stations
|
||||||
RETURNING (xmax = 0) AS inserted
|
RETURNING (xmax = 0) AS inserted
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -272,6 +294,20 @@ def save_listings(db: Session, lots: list[ScrapedLot]) -> tuple[int, int]:
|
||||||
"days_on_market": lot.days_on_market,
|
"days_on_market": lot.days_on_market,
|
||||||
"photos": _to_json(lot.photo_urls),
|
"photos": _to_json(lot.photo_urls),
|
||||||
"raw": _to_json(lot.raw_payload) if lot.raw_payload else None,
|
"raw": _to_json(lot.raw_payload) if lot.raw_payload else None,
|
||||||
|
# Cian-specific columns — None for non-Cian scrapers (→ SQL NULL)
|
||||||
|
"living_area_m2": lot.living_area_m2,
|
||||||
|
"bedrooms_count": lot.bedrooms_count,
|
||||||
|
"balconies_count": lot.balconies_count,
|
||||||
|
"loggias_count": lot.loggias_count,
|
||||||
|
"description_minhash": lot.description_minhash,
|
||||||
|
"cadastral_number": lot.cadastral_number,
|
||||||
|
"building_cadastral_number": lot.building_cadastral_number,
|
||||||
|
"phones": _to_json(lot.phones) if lot.phones else None,
|
||||||
|
"is_homeowner": lot.is_homeowner,
|
||||||
|
"is_pro_seller": lot.is_pro_seller,
|
||||||
|
"bargain_allowed": lot.bargain_allowed,
|
||||||
|
"sale_type": lot.sale_type,
|
||||||
|
"metro_stations": _to_json(lot.metro_stations) if lot.metro_stations else None,
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
|
||||||
260
tradein-mvp/backend/tests/test_base_save_listings.py
Normal file
260
tradein-mvp/backend/tests/test_base_save_listings.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""Тесты для base.py save_listings — проверяем что Cian-специфичные колонки
|
||||||
|
передаются в INSERT и ON CONFLICT DO UPDATE SET.
|
||||||
|
|
||||||
|
Используем unittest.mock для замены db.execute — без реальной БД.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.base import ScrapedLot, save_listings
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_db(inserted: bool = True) -> MagicMock:
|
||||||
|
"""Создать mock Session с fetchone() → (inserted=<bool>,)."""
|
||||||
|
row = MagicMock()
|
||||||
|
row.inserted = inserted
|
||||||
|
|
||||||
|
result = MagicMock()
|
||||||
|
result.fetchone.return_value = row
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = result
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _base_lot(**overrides) -> ScrapedLot:
|
||||||
|
"""Минимальный валидный ScrapedLot — только обязательные поля."""
|
||||||
|
defaults = {
|
||||||
|
"source": "avito",
|
||||||
|
"source_url": "https://www.avito.ru/ekaterinburg/kvartiry/1-k_987654",
|
||||||
|
"source_id": "987654",
|
||||||
|
"price_rub": 4_500_000,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return ScrapedLot(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _cian_lot(**overrides) -> ScrapedLot:
|
||||||
|
"""ScrapedLot с полным набором Cian-специфичных полей."""
|
||||||
|
base = {
|
||||||
|
"source": "cian",
|
||||||
|
"source_url": "https://ekb.cian.ru/sale/flat/123456/",
|
||||||
|
"source_id": "123456",
|
||||||
|
"price_rub": 6_200_000,
|
||||||
|
"address": "Екатеринбург, улица Постовского, 17А",
|
||||||
|
"lat": 56.8385,
|
||||||
|
"lon": 60.5940,
|
||||||
|
"rooms": 2,
|
||||||
|
"area_m2": 52.0,
|
||||||
|
"floor": 7,
|
||||||
|
"total_floors": 16,
|
||||||
|
# Cian-specific
|
||||||
|
"living_area_m2": 35.0,
|
||||||
|
"bedrooms_count": 2,
|
||||||
|
"balconies_count": 1,
|
||||||
|
"loggias_count": 0,
|
||||||
|
"description_minhash": "abc123def456abc1",
|
||||||
|
"cadastral_number": "66:41:0204016:1234",
|
||||||
|
"building_cadastral_number": "66:41:0204016:100",
|
||||||
|
"phones": [{"countryCode": "7", "number": "9012345678"}],
|
||||||
|
"is_homeowner": True,
|
||||||
|
"is_pro_seller": False,
|
||||||
|
"bargain_allowed": True,
|
||||||
|
"sale_type": "free",
|
||||||
|
"metro_stations": [{"name": "Геологическая", "time": 10, "mode": "walk"}],
|
||||||
|
"house_source": "cian",
|
||||||
|
"house_ext_id": None,
|
||||||
|
"listing_segment": "vtorichka",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return ScrapedLot(**base)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utility: extract params from mock db call ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_execute_params(db: MagicMock) -> dict:
|
||||||
|
"""Получить params dict из первого db.execute() вызова."""
|
||||||
|
assert db.execute.called, "db.execute was not called"
|
||||||
|
_sql, params = db.execute.call_args.args
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 1: Cian lot → все новые колонки попадают в params ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_persists_cian_specific_fields():
|
||||||
|
"""Cian lot с заполненными полями → все Cian колонки в params INSERT."""
|
||||||
|
db = _mock_db(inserted=True)
|
||||||
|
lot = _cian_lot()
|
||||||
|
|
||||||
|
inserted, updated = save_listings(db, [lot])
|
||||||
|
|
||||||
|
assert inserted == 1
|
||||||
|
assert updated == 0
|
||||||
|
|
||||||
|
params = _get_execute_params(db)
|
||||||
|
|
||||||
|
assert params["living_area_m2"] == 35.0
|
||||||
|
assert params["bedrooms_count"] == 2
|
||||||
|
assert params["balconies_count"] == 1
|
||||||
|
assert params["loggias_count"] == 0
|
||||||
|
assert params["description_minhash"] == "abc123def456abc1"
|
||||||
|
assert params["cadastral_number"] == "66:41:0204016:1234"
|
||||||
|
assert params["building_cadastral_number"] == "66:41:0204016:100"
|
||||||
|
assert params["is_homeowner"] is True
|
||||||
|
assert params["is_pro_seller"] is False
|
||||||
|
assert params["bargain_allowed"] is True
|
||||||
|
assert params["sale_type"] == "free"
|
||||||
|
|
||||||
|
# phones и metro_stations — JSON-строки, проверяем десериализацией
|
||||||
|
phones = json.loads(params["phones"])
|
||||||
|
assert phones == [{"countryCode": "7", "number": "9012345678"}]
|
||||||
|
|
||||||
|
metro = json.loads(params["metro_stations"])
|
||||||
|
assert metro[0]["name"] == "Геологическая"
|
||||||
|
assert metro[0]["time"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 2: Avito lot → Cian-специфичные params = None (NULL) ───────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_avito_compat_cian_cols_are_null():
|
||||||
|
"""Avito lot не имеет Cian-полей → все Cian params = None (→ SQL NULL).
|
||||||
|
|
||||||
|
Проверяет backward compat — Avito / DomRF scrapers не ломаются.
|
||||||
|
"""
|
||||||
|
db = _mock_db(inserted=True)
|
||||||
|
lot = _base_lot(source="avito", source_id="987654")
|
||||||
|
|
||||||
|
inserted, updated = save_listings(db, [lot])
|
||||||
|
|
||||||
|
assert inserted == 1
|
||||||
|
|
||||||
|
params = _get_execute_params(db)
|
||||||
|
|
||||||
|
# ScrapedLot.living_area_m2 defaults to None
|
||||||
|
assert params["living_area_m2"] is None
|
||||||
|
assert params["bedrooms_count"] is None
|
||||||
|
assert params["balconies_count"] is None
|
||||||
|
assert params["loggias_count"] is None
|
||||||
|
assert params["description_minhash"] is None
|
||||||
|
assert params["cadastral_number"] is None
|
||||||
|
assert params["building_cadastral_number"] is None
|
||||||
|
assert params["is_homeowner"] is None
|
||||||
|
assert params["is_pro_seller"] is None
|
||||||
|
assert params["bargain_allowed"] is None
|
||||||
|
assert params["sale_type"] is None
|
||||||
|
# phones=[] (default_factory=list) → None (пустой список → None в helper)
|
||||||
|
assert params["phones"] is None
|
||||||
|
# metro_stations=[] → None
|
||||||
|
assert params["metro_stations"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 3: ON CONFLICT — при updated=False все новые поля в UPDATE SET ──────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_on_conflict_updates_cian_cols():
|
||||||
|
"""ON CONFLICT path: xmax != 0 → updated counter. Params верны для UPDATE.
|
||||||
|
|
||||||
|
Проверяем что SQL-текст содержит все новые колонки в DO UPDATE SET.
|
||||||
|
"""
|
||||||
|
db = _mock_db(inserted=False) # xmax != 0 → updated
|
||||||
|
lot = _cian_lot(
|
||||||
|
source_id="111",
|
||||||
|
cadastral_number="66:41:0000000:NEW",
|
||||||
|
description_minhash="newhash",
|
||||||
|
)
|
||||||
|
|
||||||
|
inserted, updated = save_listings(db, [lot])
|
||||||
|
|
||||||
|
assert inserted == 0
|
||||||
|
assert updated == 1
|
||||||
|
|
||||||
|
# Проверяем SQL-текст на наличие DO UPDATE SET с новыми колонками
|
||||||
|
sql_obj = db.execute.call_args.args[0]
|
||||||
|
sql_text = str(sql_obj)
|
||||||
|
|
||||||
|
assert "cadastral_number = EXCLUDED.cadastral_number" in sql_text
|
||||||
|
assert "description_minhash = EXCLUDED.description_minhash" in sql_text
|
||||||
|
assert "building_cadastral_number = EXCLUDED.building_cadastral_number" in sql_text
|
||||||
|
assert "phones = EXCLUDED.phones" in sql_text
|
||||||
|
assert "is_homeowner = EXCLUDED.is_homeowner" in sql_text
|
||||||
|
assert "is_pro_seller = EXCLUDED.is_pro_seller" in sql_text
|
||||||
|
assert "bargain_allowed = EXCLUDED.bargain_allowed" in sql_text
|
||||||
|
assert "sale_type = EXCLUDED.sale_type" in sql_text
|
||||||
|
assert "metro_stations = EXCLUDED.metro_stations" in sql_text
|
||||||
|
assert "living_area_m2 = EXCLUDED.living_area_m2" in sql_text
|
||||||
|
assert "bedrooms_count = EXCLUDED.bedrooms_count" in sql_text
|
||||||
|
assert "balconies_count = EXCLUDED.balconies_count" in sql_text
|
||||||
|
assert "loggias_count = EXCLUDED.loggias_count" in sql_text
|
||||||
|
|
||||||
|
# Params с новым значением cadastral_number
|
||||||
|
params = _get_execute_params(db)
|
||||||
|
assert params["cadastral_number"] == "66:41:0000000:NEW"
|
||||||
|
assert params["description_minhash"] == "newhash"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 4: empty list → early return, no db.execute call ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_empty_lots():
|
||||||
|
"""Пустой список → (0, 0), db.execute не вызывается."""
|
||||||
|
db = _mock_db()
|
||||||
|
|
||||||
|
inserted, updated = save_listings(db, [])
|
||||||
|
|
||||||
|
assert inserted == 0
|
||||||
|
assert updated == 0
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 5: phones=[] и metro_stations=[] → None в params ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_empty_phones_and_metro_become_null():
|
||||||
|
"""Если phones=[] / metro_stations=[] — передаём None (не '[]' строку)."""
|
||||||
|
db = _mock_db(inserted=True)
|
||||||
|
lot = _cian_lot(phones=[], metro_stations=[])
|
||||||
|
|
||||||
|
save_listings(db, [lot])
|
||||||
|
|
||||||
|
params = _get_execute_params(db)
|
||||||
|
assert params["phones"] is None
|
||||||
|
assert params["metro_stations"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test 6: INSERT SQL содержит все новые колонки ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_listings_sql_contains_all_new_columns():
|
||||||
|
"""SQL INSERT перечисляет все Cian-специфичные колонки."""
|
||||||
|
db = _mock_db(inserted=True)
|
||||||
|
lot = _base_lot()
|
||||||
|
|
||||||
|
save_listings(db, [lot])
|
||||||
|
|
||||||
|
sql_text = str(db.execute.call_args.args[0])
|
||||||
|
|
||||||
|
for col in (
|
||||||
|
"living_area_m2",
|
||||||
|
"bedrooms_count",
|
||||||
|
"balconies_count",
|
||||||
|
"loggias_count",
|
||||||
|
"description_minhash",
|
||||||
|
"cadastral_number",
|
||||||
|
"building_cadastral_number",
|
||||||
|
"phones",
|
||||||
|
"is_homeowner",
|
||||||
|
"is_pro_seller",
|
||||||
|
"bargain_allowed",
|
||||||
|
"sale_type",
|
||||||
|
"metro_stations",
|
||||||
|
):
|
||||||
|
assert col in sql_text, f"Column '{col}' missing from INSERT SQL"
|
||||||
Loading…
Add table
Reference in a new issue