Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
193 lines
9.3 KiB
Python
193 lines
9.3 KiB
Python
"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали».
|
||
|
||
`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после
|
||
сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому
|
||
обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался
|
||
`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые,
|
||
что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался
|
||
как «динамика цен снова не пишется».
|
||
|
||
Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял
|
||
`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows`
|
||
игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов.
|
||
|
||
Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates`
|
||
даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом.
|
||
|
||
Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на
|
||
`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон,
|
||
который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
sys.modules.setdefault("weasyprint", MagicMock())
|
||
|
||
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||
NewbuildingSaveCounts,
|
||
save_newbuilding_enrichment,
|
||
)
|
||
|
||
from app.services import scrape_runs as runs_mod # noqa: E402
|
||
from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402
|
||
NewbuildingEnrichBackfillResult,
|
||
)
|
||
|
||
# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в
|
||
# тесте совпадало с числом в задаче.
|
||
PROD_POINTS = 64
|
||
|
||
|
||
class _UpsertDB:
|
||
"""Сессия, у которой houses_price_dynamics уже населена (второй проход).
|
||
|
||
`RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили».
|
||
"""
|
||
|
||
def __init__(self, *, already_present: bool) -> None:
|
||
self.already_present = already_present
|
||
self.price_writes = 0
|
||
self.reliability_writes = 0
|
||
self.committed = False
|
||
|
||
def execute(self, statement, params=None):
|
||
sql = str(statement)
|
||
res = MagicMock()
|
||
if "INSERT INTO houses_price_dynamics" in sql:
|
||
self.price_writes += 1
|
||
assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update"
|
||
res.fetchone.return_value = (not self.already_present,)
|
||
return res
|
||
if "INSERT INTO house_reliability_checks" in sql:
|
||
self.reliability_writes += 1
|
||
res.fetchone.return_value = None
|
||
return res
|
||
|
||
def commit(self) -> None:
|
||
self.committed = True
|
||
|
||
|
||
def _enrichment(points: int):
|
||
from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment
|
||
|
||
return NewbuildingEnrichment(
|
||
cian_internal_house_id=1,
|
||
cian_zhk_url="https://zhk-x.cian.ru/",
|
||
name="ЖК Тест",
|
||
realty_valuation_chart=[
|
||
{
|
||
"month_date": f"2026-{(i % 12) + 1:02d}-01",
|
||
"room_count": "all",
|
||
"prices_type": "price",
|
||
"period": "halfYear",
|
||
"price_per_sqm": 150000.0 + i,
|
||
}
|
||
for i in range(points)
|
||
],
|
||
reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}],
|
||
reviews=[],
|
||
)
|
||
|
||
|
||
# ── 1. Писатель различает вставку и обновление ───────────────────────────────
|
||
|
||
|
||
def test_first_pass_reports_inserts() -> None:
|
||
db = _UpsertDB(already_present=False)
|
||
counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
|
||
assert counts.price_inserted == PROD_POINTS
|
||
assert counts.price_updated == 0
|
||
assert counts.reliability_inserted == 1
|
||
assert db.price_writes == PROD_POINTS
|
||
|
||
|
||
def test_second_pass_reports_updates() -> None:
|
||
"""Прод-симптом: те же 64 точки, ничего нового — но записаны все 64.
|
||
|
||
До правки этот прогон отчитывался нулём по всем трём счётчикам.
|
||
"""
|
||
db = _UpsertDB(already_present=True)
|
||
counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS))
|
||
assert counts.price_inserted == 0
|
||
assert counts.price_updated == PROD_POINTS
|
||
assert counts.price_written == PROD_POINTS
|
||
assert db.price_writes == PROD_POINTS
|
||
|
||
|
||
def test_nothing_to_write_stays_zero() -> None:
|
||
"""Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено»."""
|
||
db = _UpsertDB(already_present=True)
|
||
counts = save_newbuilding_enrichment(db, 42, _enrichment(0))
|
||
assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0)
|
||
|
||
|
||
def test_points_without_price_are_not_counted_as_written() -> None:
|
||
"""Точка без price_per_sqm пропускается писателем — и не попадает в счёт."""
|
||
enrichment = _enrichment(2)
|
||
enrichment.realty_valuation_chart[0]["price_per_sqm"] = None
|
||
db = _UpsertDB(already_present=False)
|
||
counts = save_newbuilding_enrichment(db, 42, enrichment)
|
||
assert counts.price_written == 1
|
||
assert db.price_writes == 1
|
||
|
||
|
||
# ── 2. Сторож нулевого результата не ослаблен ────────────────────────────────
|
||
|
||
|
||
def _finalize(counters: dict[str, int]) -> str:
|
||
"""Прогнать counters через боевой финализатор и вернуть выбранный статус."""
|
||
chosen: dict[str, str] = {}
|
||
with (
|
||
patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")),
|
||
patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")),
|
||
patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")),
|
||
):
|
||
runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich")
|
||
return chosen["s"]
|
||
|
||
|
||
def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None:
|
||
"""Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали.
|
||
|
||
Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched.
|
||
Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех.
|
||
"""
|
||
result = NewbuildingEnrichBackfillResult(
|
||
processed=25,
|
||
succeeded=0,
|
||
failed_fetch=25,
|
||
price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили
|
||
)
|
||
assert _finalize(result.to_backfill_counters()) == "failed"
|
||
|
||
|
||
def test_watchdog_verdict_ignores_the_new_keys() -> None:
|
||
"""Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону."""
|
||
base = {"attempted": 25, "enriched": 3, "failed": 22}
|
||
assert _finalize(dict(base)) == "done"
|
||
assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done"
|
||
zero = {"attempted": 25, "enriched": 0, "failed": 25}
|
||
assert _finalize(dict(zero)) == "failed"
|
||
assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed"
|
||
|
||
|
||
def test_counters_carry_both_numbers_into_the_run() -> None:
|
||
"""В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль."""
|
||
counters = NewbuildingEnrichBackfillResult(
|
||
processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS
|
||
).to_backfill_counters()
|
||
assert counters["price_dynamics_inserted"] == 0
|
||
assert counters["price_dynamics_updated"] == PROD_POINTS
|
||
# Старые имена не должны остаться: у них в истории прогонов другой смысл.
|
||
assert "price_dynamics_rows" not in counters
|
||
assert "reliability_rows" not in counters
|
||
assert "review_rows" not in counters
|
||
|
||
|
||
def test_save_counts_written_is_the_sum() -> None:
|
||
assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7
|