gendesign/tradein-mvp/backend/tests/tasks/test_newbuilding_enrich_backfill.py
Light1YT bcb1285341
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 46s
Deploy Trade-In / test (push) Successful in 37s
Deploy Trade-In / build-backend (push) Successful in 45s
feat(tradein): backfill task for cian newbuilding enrichment (#972)
950-E1: app/tasks/newbuilding_enrich_backfill.py selects houses linked to
ext_source='cian_newbuilding' with a fetchable cian_zhk_url and runs the existing
fetch_newbuilding + save_newbuilding_enrichment over each, populating
houses_price_dynamics + house_reliability_checks (+ house_reviews when present).

- Idempotent + resumable: skips already-enriched houses; force= re-run dup-safe
  (price_dynamics UPSERT via dim_key, reliability dedup guard with id-DESC
  tiebreaker, reviews ON CONFLICT (source,ext_review_id)).
- Anti-bot aware: get_scraper_delay('cian') + jitter, SAVEPOINT-per-house
  (one captcha/failure logs + continues, never aborts the batch).
- limit= for bounded proof runs; sizing counters (total/fetchable/pending).

Fix cian_newbuilding._extract_transport_rate: cian transportAccessibilityRate
drifted to a nested dict and broke save_newbuilding_enrichment's CAST bind on
LIVE data (cannot adapt type 'dict') — coerce dict/float/bad -> int|None
(bool excluded before int). Fixes the live enrichment crash, not just backfill.

house_reviews stays ~0 for now: cian reviews are in a separate XHR, not the ЖК
initialState — extending fetch_newbuilding for them is a documented follow-up.

Tested: 8 new unit tests; isolated-DB proof-run landed 6 price_dynamics + 1
reliability row, idempotency proven across re-runs. Refs #972.
2026-06-07 14:09:24 +00:00

368 lines
14 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 newbuilding_enrich_backfill task (#972 / 950-E1).
No real network, no real Postgres. The Cian fetch is mocked; the DB session is a
lightweight in-memory fake that models just enough of the three target tables to
assert (a) rows land and (b) an idempotent re-run does not duplicate them.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI without GTK.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.services.scrapers.cian_newbuilding import NewbuildingEnrichment # noqa: E402
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
NewbuildingEnrichBackfillResult,
_save_cian_reviews,
backfill_newbuilding_enrichment,
)
# ---------------------------------------------------------------------------
# In-memory fake Session — models houses_price_dynamics / house_reliability_checks
# / house_reviews well enough for count + idempotency assertions.
# ---------------------------------------------------------------------------
class _FakeResult:
def __init__(self, *, scalar=None, rows=None):
self._scalar = scalar
self._rows = rows or []
def scalar_one(self):
return self._scalar
def mappings(self):
return self
def all(self):
return self._rows
class FakeDB:
"""Minimal SQLAlchemy-Session stand-in keyed off SQL text fragments.
Stores price_dynamics / reliability / reviews as sets/lists keyed by their
natural dedup key so re-inserts are no-ops (mirrors the real ON CONFLICT /
dedup-guard behaviour the task relies on).
"""
def __init__(self, pending_rows):
self._pending_rows = pending_rows # list[dict house_id, cian_zhk_url]
# price_dynamics keyed by (house_id, month_date, source, room_count, prices_type, period)
self.price_dynamics: set[tuple] = set()
# reviews keyed by (source, ext_review_id)
self.reviews: dict[tuple, dict] = {}
# reliability: list of (house_id, source)
self.reliability: list[tuple] = []
# -- query router ------------------------------------------------------
def execute(self, statement, params=None):
sql = str(statement)
params = params or {}
if "COUNT(DISTINCT h.id)" in sql and "houses_price_dynamics pd" in sql:
# _COUNT_PENDING
return _FakeResult(scalar=len(self._pending_rows))
if "COUNT(DISTINCT h.id)" in sql and "cian_zhk_url IS NOT NULL" in sql:
# _COUNT_FETCHABLE
return _FakeResult(scalar=len(self._pending_rows))
if "COUNT(DISTINCT h.id)" in sql:
# _COUNT_TOTAL
return _FakeResult(scalar=len(self._pending_rows))
if "SELECT DISTINCT h.id AS house_id" in sql:
lim = params.get("lim", len(self._pending_rows))
return _FakeResult(rows=self._pending_rows[:lim])
if "COUNT(*) FROM houses_price_dynamics" in sql:
h = params["h"]
return _FakeResult(scalar=sum(1 for k in self.price_dynamics if k[0] == h))
if "COUNT(*) FROM house_reliability_checks" in sql:
h = params["h"]
return _FakeResult(scalar=sum(1 for k in self.reliability if k[0] == h))
if "COUNT(*) FROM house_reviews" in sql:
h = params["h"]
return _FakeResult(scalar=sum(1 for k, v in self.reviews.items() if v["house_id"] == h))
if "INSERT INTO house_reviews" in sql:
key = (params["source"], params["ext_review_id"])
self.reviews[key] = {"house_id": params["house_id"], **params} # UPSERT
return _FakeResult()
if "DELETE FROM house_reliability_checks" in sql:
# dedup guard — keep newest only (model: keep 1 per (house, source))
h, src = params["h"], params["src"]
kept = [r for r in self.reliability if not (r[0] == h and r[1] == src)]
kept.append((h, src))
self.reliability = kept
return _FakeResult()
# Unhandled SQL → inert result (keeps the fake permissive).
return _FakeResult()
# -- transaction shims -------------------------------------------------
def begin_nested(self):
return MagicMock()
def commit(self):
pass
def rollback(self):
pass
def _enrichment_with_everything(seed: int = 0) -> NewbuildingEnrichment:
"""Build a full enrichment. `seed` shifts review ids + month so distinct ЖК pages
produce distinct rows (house_reviews dedups on (source, ext_review_id) globally,
not per-house — real pages have unique ids; the seed mimics that)."""
month = f"2025-{(seed % 12) + 1:02d}-01"
base = 9000 + seed * 10
return NewbuildingEnrichment(
cian_internal_house_id=12345 + seed,
cian_zhk_url=f"https://zhk-test-{seed}-ekb-i.cian.ru/",
name="ЖК Тест",
realty_valuation_chart=[
{
"month_date": month,
"room_count": "all",
"prices_type": "price",
"period": "halfYear",
"price_per_sqm": 13800000.0,
}
],
reliability_checks=[
{"check_name": "Надёжный застройщик", "check_status": "reliable", "details": []}
],
reviews=[
{"id": base + 1, "title": "Отличный ЖК", "rate": 5, "text": "Очень доволен"},
{"id": base + 2, "title": "Норм", "rate": 4, "text": "Есть нюансы"},
],
)
def _fake_save_newbuilding_enrichment(db, house_id, enrichment):
"""Stand-in for the real saver: lands price_dynamics + reliability into FakeDB."""
for p in enrichment.realty_valuation_chart:
if p.get("price_per_sqm") is None:
continue
db.price_dynamics.add(
(
house_id,
p["month_date"],
"cian_realty_valuation",
p.get("room_count", "all"),
p.get("prices_type", "price"),
p.get("period", "halfYear"),
)
)
for c in enrichment.reliability_checks:
if c.get("check_name") or c.get("check_status"):
db.reliability.append((house_id, "cian_nashdom"))
db.commit()
# ---------------------------------------------------------------------------
# Pure-unit: _save_cian_reviews mapping (the load-bearing new logic).
# ---------------------------------------------------------------------------
def test_save_cian_reviews_maps_and_dedups() -> None:
db = FakeDB(pending_rows=[])
reviews = [
{"id": 1, "title": "Хорошо", "rate": 5, "text": "ok"},
{"id": 2, "title": "Плохо", "rate": 2, "text": "bad"},
]
n = _save_cian_reviews(db, house_id=7, reviews=reviews)
assert n == 2
assert db.reviews[("cian", 1)]["house_id"] == 7
assert db.reviews[("cian", 1)]["review_title"] == "Хорошо"
# Re-run with same ids → UPSERT, no growth (idempotent on (source, ext_review_id)).
n2 = _save_cian_reviews(db, house_id=7, reviews=reviews)
assert n2 == 2
assert len(db.reviews) == 2
def test_save_cian_reviews_skips_entries_without_id() -> None:
db = FakeDB(pending_rows=[])
reviews = [{"title": "no id", "text": "x"}, {"id": "not-int", "text": "y"}]
n = _save_cian_reviews(db, house_id=7, reviews=reviews)
assert n == 0
assert len(db.reviews) == 0
def test_save_cian_reviews_clamps_bad_score() -> None:
db = FakeDB(pending_rows=[])
_save_cian_reviews(db, house_id=7, reviews=[{"id": 5, "rate": 99}])
assert db.reviews[("cian", 5)]["score"] is None # out of 1..5 → dropped
# ---------------------------------------------------------------------------
# Task-level: end-to-end populate + idempotent re-run.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_backfill_dry_run_counts_only() -> None:
db = FakeDB(pending_rows=[{"house_id": 1, "cian_zhk_url": "https://zhk-a.cian.ru/"}])
with patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding", new_callable=AsyncMock
) as mock_fetch:
result = await backfill_newbuilding_enrichment(db, limit=5, dry_run=True)
mock_fetch.assert_not_called()
assert isinstance(result, NewbuildingEnrichBackfillResult)
assert result.cian_houses_total == 1
assert result.processed == 0
@pytest.mark.asyncio
async def test_backfill_populates_all_three_tables() -> None:
db = FakeDB(
pending_rows=[
{"house_id": 1, "cian_zhk_url": "https://zhk-a.cian.ru/"},
{"house_id": 2, "cian_zhk_url": "https://zhk-b.cian.ru/"},
]
)
# Distinct enrichment per house (real ЖК pages have unique review ids / months).
by_url = {
"https://zhk-a.cian.ru/": _enrichment_with_everything(seed=1),
"https://zhk-b.cian.ru/": _enrichment_with_everything(seed=2),
}
async def _fetch(url, **kwargs):
return by_url[url]
with (
patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding",
side_effect=_fetch,
),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
result = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
assert result.processed == 2
assert result.succeeded == 2
assert result.failed_fetch == 0
assert result.failed_save == 0
# All three tables non-zero.
assert len(db.price_dynamics) == 2 # 1 chart point × 2 houses
assert len(db.reliability) == 2
assert len(db.reviews) == 4 # 2 reviews × 2 houses
assert result.price_dynamics_rows == 2
assert result.reliability_rows == 2
assert result.review_rows == 4
@pytest.mark.asyncio
async def test_backfill_force_rerun_no_dup() -> None:
"""force=True re-run over the same house must not duplicate any of the 3 tables."""
rows = [{"house_id": 1, "cian_zhk_url": "https://zhk-a.cian.ru/"}]
db = FakeDB(pending_rows=rows)
enr = _enrichment_with_everything()
with (
patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding",
new_callable=AsyncMock,
return_value=enr,
),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
pd_after_first = len(db.price_dynamics)
rv_after_first = len(db.reviews)
# Re-run with force=True → re-process the same house.
result2 = await backfill_newbuilding_enrichment(
db, limit=5, force=True, request_delay_sec=0
)
assert len(db.price_dynamics) == pd_after_first # ON CONFLICT dedup
assert len(db.reviews) == rv_after_first # (source, ext_review_id) dedup
assert len(db.reliability) == 1 # dedup guard collapsed to newest
assert result2.processed == 1
@pytest.mark.asyncio
async def test_backfill_default_rerun_skips_and_no_reliability_dup() -> None:
"""force=False resumability: an already-enriched house (pd+reliability present, NO
reviews — the common Cian case) is skipped on the next run, so a plain re-run neither
re-fetches nor accumulates duplicate reliability rows.
"""
rows = [{"house_id": 1, "cian_zhk_url": "https://zhk-a.cian.ru/"}]
db = FakeDB(pending_rows=rows)
enr = _enrichment_with_everything()
enr.reviews = [] # mimic a ЖК whose initialState carries no reviews
fetch_calls = {"n": 0}
async def _fetch(url, **kwargs):
fetch_calls["n"] += 1
return enr
with (
patch("app.services.scrapers.cian_newbuilding.fetch_newbuilding", side_effect=_fetch),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
# First run enriches.
r1 = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
# FakeDB._pending_rows is static, so the SELECT still returns the house; the task's
# per-house guard must skip it now that pd+reliability exist.
r2 = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
assert r1.succeeded == 1
assert fetch_calls["n"] == 1 # second run skipped → no extra fetch
assert r2.skipped_already_enriched == 1
assert r2.succeeded == 0
assert len(db.reliability) == 1 # no duplicate appended on re-run
@pytest.mark.asyncio
async def test_backfill_fetch_failure_does_not_abort_batch() -> None:
"""One house returns None (captcha) → counted as fetch-fail, batch continues."""
db = FakeDB(
pending_rows=[
{"house_id": 1, "cian_zhk_url": "https://zhk-a.cian.ru/"},
{"house_id": 2, "cian_zhk_url": "https://zhk-b.cian.ru/"},
]
)
enr = _enrichment_with_everything()
async def _fetch(url, **kwargs):
return None if "zhk-a" in url else enr
with (
patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding",
side_effect=_fetch,
),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
result = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
assert result.processed == 2
assert result.failed_fetch == 1
assert result.succeeded == 1
assert len(db.reviews) == 2 # only house 2 enriched