Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Failing after 3m29s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
224 lines
9.7 KiB
Python
224 lines
9.7 KiB
Python
"""#2674 — домовая оценка Авито перестаёт врать про ремонт и тип дома.
|
||
|
||
Покрывает три дефекта из эпика:
|
||
1. renovation_type берётся из моды listings.repair_state и проходит через
|
||
существующий estimator._IMV_REPAIR_MAP (был захардкожен литерал 'cosmetic':
|
||
2685 из 2685 запросов ушли как «косметический ремонт»).
|
||
2. Неизвестный тип дома НЕ уезжает дефолтом 'panel' (самый дешёвый класс →
|
||
системное занижение), а помечает дом и экономит запрос. Отдельно —
|
||
camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / stalin)
|
||
распознаётся, к нижнему регистру он не приводится.
|
||
3. Прогон с saved=0 и ненулевыми errors не помечается 'done'.
|
||
|
||
БД и сеть замоканы — реального Postgres/Авито не нужно.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import house_imv_backfill as hib
|
||
from app.services.product_handlers import _job_house_imv_backfill
|
||
|
||
# ── (1) renovation_type из данных через существующий маппинг ──────────────────
|
||
|
||
|
||
def _db_for_pick(listing_row: dict[str, Any], house_row: dict[str, Any] | None) -> MagicMock:
|
||
"""MagicMock-Session: два .mappings().first() подряд (listings-агрегат, houses)."""
|
||
db = MagicMock()
|
||
db.execute.return_value.mappings.return_value.first.side_effect = [listing_row, house_row]
|
||
return db
|
||
|
||
|
||
def _listing_row(**over: Any) -> dict[str, Any]:
|
||
base: dict[str, Any] = {
|
||
"rooms": 2,
|
||
"area_m2": 52.0,
|
||
"floor": 3,
|
||
"total_floors": 10,
|
||
"house_type": "panel",
|
||
"repair_state": None,
|
||
}
|
||
base.update(over)
|
||
return base
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("repair_state", "expected"),
|
||
[
|
||
("needs_repair", "required"),
|
||
("standard", "cosmetic"),
|
||
("good", "euro"),
|
||
("excellent", "designer"),
|
||
],
|
||
)
|
||
def test_renovation_type_comes_from_listings_via_estimator_map(
|
||
repair_state: str, expected: str
|
||
) -> None:
|
||
"""Мода repair_state → renovation_type ровно по estimator._IMV_REPAIR_MAP."""
|
||
from app.services.estimator import _IMV_REPAIR_MAP
|
||
|
||
params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=repair_state), None), 1)
|
||
|
||
assert params["renovation_type"] == expected
|
||
# Не второй словарь: значение обязано совпадать с источником правды.
|
||
assert params["renovation_type"] == _IMV_REPAIR_MAP[repair_state]
|
||
|
||
|
||
def test_renovation_type_not_hardcoded_cosmetic() -> None:
|
||
"""Regression #2674: 'good' больше не превращается в 'cosmetic'."""
|
||
params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state="good"), None), 1)
|
||
assert params["renovation_type"] != "cosmetic"
|
||
|
||
|
||
def test_unknown_repair_state_stays_cosmetic() -> None:
|
||
"""Анти-оверрич: ремонт неизвестен → середина шкалы 'cosmetic', дом не теряем."""
|
||
params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=None), None), 1)
|
||
assert params["renovation_type"] == "cosmetic"
|
||
assert params["house_type"] == "panel" # дом всё ещё пригоден к запросу
|
||
|
||
|
||
# ── (2) тип дома: неизвестный не врёт, camelCase распознаётся ─────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
# camelCase из Циана — нижним регистром НЕ лечится (ключ канона через '_').
|
||
("monolithBrick", "monolithic"),
|
||
("gasSilicateBlock", "block"),
|
||
("aerocreteBlock", "block"),
|
||
("stalin", "brick"),
|
||
# каноничные значения продолжают работать
|
||
("panel", "panel"),
|
||
("monolith", "monolithic"),
|
||
("monolith_brick", "monolithic"),
|
||
],
|
||
)
|
||
def test_map_house_type_recognises_camel_case(raw: str, expected: str) -> None:
|
||
assert hib._map_house_type(raw) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("raw", [None, "", "other", "wireframe", "какая-то дичь"])
|
||
def test_map_house_type_unknown_is_none_not_panel(raw: str | None) -> None:
|
||
"""Regression #2674: нет типа / не распознан → None, а НЕ дефолт 'panel'."""
|
||
assert hib._map_house_type(raw) is None
|
||
|
||
|
||
def test_pick_lot_params_unknown_house_type_yields_none() -> None:
|
||
"""Типа нет ни в listings, ни в houses → house_type=None (не 'panel')."""
|
||
db = _db_for_pick(_listing_row(house_type=None), {"house_type": None, "total_floors": 9})
|
||
assert hib.pick_lot_params(db, 1)["house_type"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unknown_house_type_skips_request_and_marks_house() -> None:
|
||
"""Неизвестный тип → запрос к площадке НЕ уходит, дом помечен no_params."""
|
||
params = {
|
||
"rooms": 2,
|
||
"area_m2": 52.0,
|
||
"floor": 3,
|
||
"floor_at_home": 10,
|
||
"house_type": None,
|
||
"renovation_type": "cosmetic",
|
||
"has_balcony": True,
|
||
"has_loggia": False,
|
||
}
|
||
houses = [{"id": 11, "address": "ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}]
|
||
db = MagicMock()
|
||
|
||
with (
|
||
patch.object(hib, "pick_lot_params", return_value=params),
|
||
patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval,
|
||
patch.object(hib, "_mark_status") as mock_mark,
|
||
):
|
||
# #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error,
|
||
# затем основную по only_status. Фейк отвечает одним и тем же списком на любой
|
||
# SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом
|
||
# придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются).
|
||
db.execute.return_value.mappings.return_value.all.side_effect = [[], houses]
|
||
result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0)
|
||
|
||
mock_eval.assert_not_called()
|
||
mock_mark.assert_called_once_with(db, 11, "no_params", "unknown house_type")
|
||
assert result.skipped == 1
|
||
assert result.saved == 0
|
||
|
||
|
||
# ── (3) прогон с нулём сохранённых и ошибками не «успешен» ────────────────────
|
||
|
||
|
||
class _RunsRecorder:
|
||
"""Duck-typed ctx.runs: пишет, чем закончился прогон."""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||
|
||
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||
return None
|
||
|
||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||
self.calls.append(("mark_done", counters))
|
||
|
||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||
self.calls.append(("mark_failed", counters))
|
||
|
||
|
||
async def _drive_job(*, saved: int, errors: int, skipped: int = 0) -> list[tuple[str, dict]]:
|
||
runs = _RunsRecorder()
|
||
enrichment = MagicMock()
|
||
enrichment.house_imv_backfill = AsyncMock(
|
||
return_value=hib.HouseIMVBackfillResult(
|
||
checked=saved + errors + skipped,
|
||
saved=saved,
|
||
skipped=skipped,
|
||
errors=errors,
|
||
duration_sec=1.0,
|
||
)
|
||
)
|
||
ctx = MagicMock(runs=runs, enrichment=enrichment)
|
||
await _job_house_imv_backfill(MagicMock(), 1, {}, ctx)
|
||
return runs.calls
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_zero_saved_with_errors_is_not_done() -> None:
|
||
"""Прод-случай: 31 прогон подряд saved=0 / errors≈35 из 50 уходил в 'done'."""
|
||
calls = await _drive_job(saved=0, errors=35, skipped=15)
|
||
assert calls[-1][0] == "mark_failed"
|
||
assert calls[-1][1]["saved"] == 0
|
||
assert calls[-1][1]["errors"] == 35
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_honest_empty_stays_done() -> None:
|
||
"""Анти-оверрич: ноль сохранённых без ошибок (всё в skipped) — честная пустота."""
|
||
calls = await _drive_job(saved=0, errors=0, skipped=50)
|
||
assert calls[-1][0] == "mark_done"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partial_success_stays_done() -> None:
|
||
"""Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки."""
|
||
calls = await _drive_job(saved=3, errors=7)
|
||
assert calls[-1][0] == "mark_done"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_counters_feed_total_seen_and_new_count() -> None:
|
||
"""#2674: без этих ключей _column_counts оставляет колонку total_seen=0,
|
||
|
||
и алерт «3 подряд done с нулевым результатом» (#2625) выстрелил бы даже на
|
||
полностью успешном прогоне. На проде так лежат все 39 прогонов source'а.
|
||
"""
|
||
from app.services.scrape_runs import _column_counts
|
||
|
||
calls = await _drive_job(saved=50, errors=0)
|
||
counters = calls[-1][1]
|
||
assert _column_counts(counters) == (50, 50)
|