gendesign/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py
bot-backend 72472c2783
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
fix(tradein/newbuilding): счётчики записи различают вставку и обновление (#2807) (#2809)
2026-08-10 08:50:56 +00:00

193 lines
9.3 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.

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