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