"""Unit-тесты для DomClick Layer B detail-scraper (scraper_kit.providers.domclick.detail). Offline: parse/SSR-тесты без сети/БД; save-тест через MagicMock (зеркало test_cian_detail.py — в проекте нет live-DB фикстуры, идемпотентность гарантируется ON CONFLICT-клаузой, которая проверяется на уровне SQL-строки). Покрытие: - _extract_ssr_state: bare `undefined` → null; in-string "undefined" выживает; скобки/эскейпы внутри строк не ломают balanced-scan; missing marker → DomClickParseError; challenge → DomClickBlockedError; unbalanced/невалидный JSON → DomClickParseError. - parse_detail_html (REAL __SSR_STATE__ paths, live card 2075729321): productCard.objectInfo.{renovation→repair_state+repair_type,livingArea,kitchenArea}; balconies absent→None; productCard.legalOptions.saleType; egrnData snake_case owners_count + encumbrances инверсия (collateral); houseInfo.info.{wallType,floorType} → raw_extra (НЕ house_type); productCard.viewsCount; priceInfo.priceHistory (ISO8601+offset kept tz-aware / bad-date + no-price skipped); top-level pricePrediction → raw_extra.avm (плоский + nested .result); raw_extra без None; item_id из URL. - fetch_detail: browser-branch + проброс ошибок. - save_detail_enrichment: UPDATE-параметры, detail_enriched_at, price-history INSERT. Легаси `app.services.scrapers.domclick_detail` удалён (#2277 финальный шаг scraper_kit-миграции, 0 runtime importers) — тесты переведены на kit-эквивалент `scraper_kit.providers.domclick.detail` без изменения покрытия. Exceptions тоже переведены на `scraper_kit.domclick_exceptions` (kit держит свою независимую копию, `parse_detail_html`(kit) поднимает именно её). Legacy `app.services.scrapers. domclick`/`domclick_exceptions` удалены целиком (#2397 Part E2, 0 runtime importers) — `scraper_kit.domclick_exceptions` остаётся единственным живым классом для `pytest.raises`. """ from __future__ import annotations import json import logging from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock import httpx import pytest from scraper_kit.browser_fetcher import SidecarBanPageError from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.offer_price_history import validate_diff_percent from scraper_kit.providers.domclick.detail import ( DomClickDetailEnrichment, _extract_ssr_state, canon_sale_type, fetch_detail, parse_detail_html, save_detail_enrichment, ) _CARD_URL = "https://ekaterinburg.domclick.ru/card/sale__flat__2075729321" # Литерал с bare `undefined` (value-позиция), in-string "undefined", скобкой # внутри строки и эскейп-кавычками — написан вручную, json.dumps использовать # нельзя (нужен именно НЕвалидный-для-json undefined). # Real __SSR_STATE__ shape (ground truth, live card 2075729321, 2026-06-27). # bare `undefined` (value-pos), in-string "undefined", '{' внутри строки и # эскейп-кавычки оставлены для проверки санитайзера/balanced-scan; json.dumps # использовать нельзя (нужен именно НЕвалидный-для-json undefined). # priceHistory[0].date намеренно flush-left (длинная ISO+offset строка) — отступ # в литерале не влияет на парсер, но держит физическую строку ≤100 для ruff. _SSR_LITERAL = """{ "productCard": { "objectInfo": { "area": 38.0, "rooms": 1, "floor": 5, "isApartment": false, "renovation": "евроремонт", "livingArea": 22.37, "kitchenArea": 10.13, "description": "Состояние пока undefined.", "note": "угол {da}", "quote": "сказал \\"привет\\" сосед" }, "legalOptions": {"saleType": "Свободная продажа"}, "egrnData": { "area": 38.2, "floor": 5, "owners_count": 1, "collateral": true, "collateral_sber": false }, "priceInfo": { "priceHistory": [ {"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"}, {"date": "2026-03-01T09:00:00+03:00", "price": 13800000, "diff": -200000, "state": "less"}, {"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"}, {"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"} ] }, "viewsCount": 338, "callsCount": 5, "favoriteOfferUsersCount": 12, "duplicatesOfferCount": 0, "address": {"guid": "abc-guid-123"}, "unknownField": undefined }, "houseInfo": { "info": { "buildYear": 2015, "wallType": "Кирпично-монолитный", "floorType": "Железобетонный", "entranceCount": 3, "quartersCount": 120, "energyEfficiency": "B", "buildingSeries": "индивидуальный" } }, "pricePrediction": { "market_price": 13000000, "min_market_price": 12000000, "max_market_price": 14000000, "rent_short": 50000, "rent_long": 35000, "repair_quality": "good" } }""" _HTML = ( "
" ) def _wrap(state: dict) -> str: """Заворачивает валидный dict в SSR-HTML (для variant-фикстур без undefined).""" literal = json.dumps(state, ensure_ascii=False) return f"" # ── _extract_ssr_state ──────────────────────────────────────────────────────── def test_extract_ssr_state_parses_despite_undefined() -> None: state = _extract_ssr_state(_HTML) # bare undefined в value-позиции → null → None assert state["productCard"]["unknownField"] is None # in-string "undefined" НЕ испорчен санитайзером assert state["productCard"]["objectInfo"]["description"] == "Состояние пока undefined." def test_extract_ssr_state_brace_and_escape_in_string() -> None: state = _extract_ssr_state(_HTML) oi = state["productCard"]["objectInfo"] # '{' внутри строки не ломает balanced-brace scan assert oi["note"] == "угол {da}" # эскейп-кавычки внутри строки обработаны assert oi["quote"] == 'сказал "привет" сосед' def test_extract_ssr_state_missing_marker_raises_parse() -> None: with pytest.raises(DomClickParseError): _extract_ssr_state("no ssr state here") def test_extract_ssr_state_challenge_raises_blocked() -> None: html = "Access denied by DataDome captcha" with pytest.raises(DomClickBlockedError): _extract_ssr_state(html) # ── QRATOR markers (#2636) — Layer B had only 4/7 canonical markers, so a # QRATOR block page using bot_mitigation/система защиты/403 | домклик fell # through to DomClickParseError instead of DomClickBlockedError (block-breaker # never triggered, batch burned through instead of pausing). Case varied per # marker to exercise the case-insensitive comparison. @pytest.mark.parametrize( "html", [ "BOT_MITIGATION в процессе, подождите", "СИСТЕМА ЗАЩИТЫ от ботов активна", "403 | ДОМКЛИК — доступ ограничен", ], ids=["bot_mitigation_upper", "sistema_zashchity_upper", "403_domklik_upper"], ) def test_extract_ssr_state_qrator_markers_raise_blocked_not_parse(html: str) -> None: with pytest.raises(DomClickBlockedError): _extract_ssr_state(html) def test_extract_ssr_state_unbalanced_raises_parse() -> None: with pytest.raises(DomClickParseError): _extract_ssr_state('window.__SSR_STATE__ = {"a": 1') def test_extract_ssr_state_invalid_json_raises_parse() -> None: with pytest.raises(DomClickParseError): _extract_ssr_state("window.__SSR_STATE__ = {this: is, not: json}") # ── parse_detail_html — полная карточка ─────────────────────────────────────── def test_parse_detail_html_full() -> None: e = parse_detail_html(_HTML, _CARD_URL) assert e.item_id == "2075729321" assert e.source_url == _CARD_URL assert e.repair_state == "good" # евроремонт → good (LOCAL map) assert e.repair_type == "евроремонт" # сырое значение сохранено assert e.living_area_m2 == 22.37 assert e.kitchen_area_m2 == 10.13 # На реальной карточке нет ключа balconies в objectInfo → optional/None. assert e.balconies_count is None assert e.has_balcony is None assert e.sale_type == "Свободная продажа" assert e.owners_count == 1 assert e.encumbrances_clean is False # collateral=true → есть обременение assert e.year_built == 2015 assert e.views_total == 338 def test_parse_detail_html_price_changes() -> None: e = parse_detail_html(_HTML, _CARD_URL) # 2 валидные записи; bad-date skip; price=null skip. Порядок — по change_time. assert len(e.price_changes) == 2 first, second = e.price_changes assert [c["price_rub"] for c in e.price_changes] == [13800000, 13400000] # Процент считается из соседних price_rub, поле diff площадки (рубли) игнорируется # (#3225): 13 400 000 / 13 800 000 − 1 = −2.9%, а НЕ −400000. assert first["diff_percent"] is None # самая ранняя запись: предыдущей цены нет assert second["diff_percent"] == -2.9 assert isinstance(second["change_time"], datetime) # ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC. assert second["change_time"].tzinfo is not None assert second["change_time"].utcoffset() == timedelta(hours=3) def test_parse_detail_html_price_changes_single_entry_has_no_percent() -> None: html = _wrap( { "productCard": { "priceInfo": { "priceHistory": [ {"date": "2026-04-15T09:00:00+03:00", "price": 9000000, "diff": -300000} ] } } } ) e = parse_detail_html(html, _CARD_URL) assert [c["diff_percent"] for c in e.price_changes] == [None] def test_parse_detail_html_raw_extra() -> None: e = parse_detail_html(_HTML, _CARD_URL) raw = e.raw_extra assert raw["wall_type"] == "Кирпично-монолитный" assert raw["floor_type"] == "Железобетонный" # wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type. assert "house_type" not in raw assert raw["domclick_building_guid"] == "abc-guid-123" assert raw["egrn_area"] == 38.2 assert raw["demand"]["calls"] == 5 assert raw["demand"]["favorites"] == 12 # AVM (Layer C, top-level pricePrediction) → raw_extra.avm assert raw["avm"]["market_price"] == 13000000 assert raw["avm"]["min"] == 12000000 assert raw["avm"]["max"] == 14000000 assert raw["avm"]["repair_quality"] == "good" # нет ключа balconies → raw_extra НЕ содержит balconies_raw assert "balconies_raw" not in raw # никаких None-значений на верхнем уровне (компактность) assert all(v is not None for v in raw.values()) # ── parse_detail_html — частные ветки ───────────────────────────────────────── def test_parse_balconies_string_goes_to_raw_extra() -> None: html = _wrap({"productCard": {"objectInfo": {"balconies": "есть"}}}) e = parse_detail_html(html, _CARD_URL) assert e.balconies_count is None assert e.has_balcony is None assert e.raw_extra["balconies_raw"] == "есть" def test_parse_encumbrances_clean_true_when_no_collateral() -> None: html = _wrap({"productCard": {"egrnData": {"collateral": False, "collateral_sber": False}}}) e = parse_detail_html(html, _CARD_URL) assert e.encumbrances_clean is True def test_parse_encumbrances_none_when_egrn_missing() -> None: html = _wrap({"productCard": {"objectInfo": {"livingArea": 30}}}) e = parse_detail_html(html, _CARD_URL) assert e.encumbrances_clean is None def test_parse_repair_map_hit() -> None: html = _wrap({"productCard": {"objectInfo": {"renovation": "Косметический"}}}) e = parse_detail_html(html, _CARD_URL) assert e.repair_state == "standard" assert e.repair_type == "Косметический" def test_parse_repair_fallback_to_inference() -> None: # Не точное совпадение в карте → инференс из текста (евроремонт → good) html = _wrap({"productCard": {"objectInfo": {"renovation": "сделан свежий евроремонт"}}}) e = parse_detail_html(html, _CARD_URL) assert e.repair_state == "good" assert e.repair_type == "сделан свежий евроремонт" def test_parse_owners_count_camelcase_key() -> None: html = _wrap({"productCard": {"egrnData": {"ownersCount": 3}}}) e = parse_detail_html(html, _CARD_URL) assert e.owners_count == 3 def test_parse_avm_nested_under_result() -> None: # pricePrediction может приходить обёрнутым в .result — unwrap должен сработать. html = _wrap( { "productCard": {}, "pricePrediction": {"result": {"marketPrice": 9000000, "repairQuality": "fine"}}, } ) e = parse_detail_html(html, _CARD_URL) assert e.raw_extra["avm"]["market_price"] == 9000000 assert e.raw_extra["avm"]["repair_quality"] == "fine" def test_parse_avm_absent_no_key() -> None: # Нет pricePrediction → raw_extra без ключа avm (компактность). e = parse_detail_html(_wrap({"productCard": {}}), _CARD_URL) assert "avm" not in e.raw_extra def test_parse_item_id_none_for_bad_url() -> None: e = parse_detail_html(_wrap({"productCard": {}}), "https://domclick.ru/card/rent__room__1") assert e.item_id is None def test_dataclass_defaults() -> None: e = DomClickDetailEnrichment() assert e.item_id is None assert e.price_changes == [] assert e.raw_extra == {} # ── fetch_detail (browser-branch) ───────────────────────────────────────────── @pytest.mark.asyncio async def test_fetch_detail_parses_via_browser() -> None: bf = MagicMock() bf.fetch = AsyncMock(return_value=_HTML) e = await fetch_detail(_CARD_URL, browser_fetcher=bf) assert e.item_id == "2075729321" assert e.repair_state == "good" bf.fetch.assert_called_once_with( _CARD_URL, origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", cookies=None, ) @pytest.mark.asyncio async def test_fetch_detail_passes_derived_vtorichka_origin() -> None: """fetch_detail выводит origin из scheme+netloc card_url → vtorichka-SERP root. Организм-навигация (SERP до карточки) снижает подозрительность холодного захода для QRATOR — см. комментарий в fetch_detail. Проверяем сам вывод origin для другого хоста/пути, не завязываясь на _CARD_URL из happy-path теста выше. """ bf = MagicMock() bf.fetch = AsyncMock(return_value=_HTML) other_card_url = "https://spb.domclick.ru/card/sale__flat__999888777" await fetch_detail(other_card_url, browser_fetcher=bf) bf.fetch.assert_called_once_with( other_card_url, origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka", referer="https://spb.domclick.ru/pokupka/kvartiry/vtorichka", cookies=None, ) @pytest.mark.asyncio async def test_fetch_detail_passes_cookies_through_to_browser_fetcher() -> None: """fetch_detail(cookies=...) пробрасывает cookies в browser_fetcher.fetch без изменений. cookies — сессия из app.services.domclick_session.load_session (см. admin debug-роут /scrape/domclick/debug/detail-fetch) — этот модуль сам её не загружает (kit не импортирует app.*), только пробрасывает то, что передал caller. """ bf = MagicMock() bf.fetch = AsyncMock(return_value=_HTML) session_cookies = {"CAS_ID": "12345", "qrator_jsid2": "abc"} await fetch_detail(_CARD_URL, browser_fetcher=bf, cookies=session_cookies) bf.fetch.assert_called_once_with( _CARD_URL, origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", cookies=session_cookies, ) @pytest.mark.asyncio async def test_fetch_detail_browser_failure_raises_blocked() -> None: bf = MagicMock() bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway")) with pytest.raises(DomClickBlockedError): await fetch_detail(_CARD_URL, browser_fetcher=bf) @pytest.mark.asyncio async def test_fetch_detail_propagates_parse_error() -> None: bf = MagicMock() bf.fetch = AsyncMock(return_value="no state") with pytest.raises(DomClickParseError): await fetch_detail(_CARD_URL, browser_fetcher=bf) @pytest.mark.asyncio async def test_fetch_detail_propagates_blocked_from_html() -> None: bf = MagicMock() bf.fetch = AsyncMock(return_value="Access denied datadome") with pytest.raises(DomClickBlockedError): await fetch_detail(_CARD_URL, browser_fetcher=bf) # ── fetch_detail: report_ban на ГЕНУИННЫЙ маркер-детект (#2600 п.1) ──────────── # # Различие: QRATOR-маркеры в HTML (parse_detail_html) — надёжно распознанный бан, # report_ban ДОЛЖЕН вызываться. Голая ошибка транспорта (bf.fetch кинул) — это # сетевой/инфраструктурный сбой, НЕ подтверждённый бан-маркер, report_ban НЕ # вызывается (issue #2600 п.4 — не смешивать «бан» и «сетевой сбой»). @pytest.mark.asyncio async def test_fetch_detail_reports_ban_on_marker_detected_block() -> None: bf = MagicMock() bf.fetch = AsyncMock(return_value="Access denied datadome") with pytest.raises(DomClickBlockedError): await fetch_detail(_CARD_URL, browser_fetcher=bf) bf.report_ban.assert_called_once() assert _CARD_URL in bf.report_ban.call_args.args[0] @pytest.mark.asyncio async def test_fetch_detail_transport_failure_does_not_report_ban() -> None: """bf.fetch() кинул (502/timeout/network) — DomClickBlockedError поднимается как обёртка (см. except Exception ветку fetch_detail), но report_ban НЕ вызывается — это не подтверждённый маркер-бан, а транспортная ошибка.""" bf = MagicMock() bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway")) with pytest.raises(DomClickBlockedError): await fetch_detail(_CARD_URL, browser_fetcher=bf) bf.report_ban.assert_not_called() # ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ──────────── # # #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в # сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал # приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по # построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не # запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ # банит, голый транспортный сбой — нет (тест выше). def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError: return SidecarBanPageError( "Server error '500' | tradein-browser: BanPageDetectedError: статический отказ", request=httpx.Request("POST", "http://tradein-browser:3000/fetch"), response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")), upstream_status=upstream_status, ) @pytest.mark.asyncio async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None: """Sidecar-бан рапортует сам фетчер (`_report_platform_ban`, #3288) — здесь уже нет. Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы свежий узел. Ветка `parse_detail_html` ниже — другой случай: тот детект наш, фетчер его не видит, и там report_ban остаётся (см. тест следом за этим блоком). """ bf = MagicMock() bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error()) with pytest.raises(DomClickBlockedError): await fetch_detail(_CARD_URL, browser_fetcher=bf) bf.report_ban.assert_not_called() @pytest.mark.asyncio async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None: """Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status. Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status, поэтому у Домклика (отказ приходит с 401) классификатор без этого поля поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock — если бы код читал его, ассерт ниже упал бы. """ bf = MagicMock() bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401)) with pytest.raises(DomClickBlockedError) as excinfo: await fetch_detail(_CARD_URL, browser_fetcher=bf) assert excinfo.value.status == 401 @pytest.mark.asyncio async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None: """Сайдкар не отдал статус → None, а не выдуманный код (#2764).""" bf = MagicMock() bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None)) with pytest.raises(DomClickBlockedError) as excinfo: await fetch_detail(_CARD_URL, browser_fetcher=bf) assert excinfo.value.status is None # ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ───────────── def test_save_detail_enrichment_writes_columns() -> None: db = MagicMock() db.execute.return_value.rowcount = 1 ct = datetime(2026, 4, 1, tzinfo=UTC) e = DomClickDetailEnrichment( item_id="2075729321", source_url=_CARD_URL, repair_state="good", repair_type="Евроремонт", living_area_m2=45.5, kitchen_area_m2=12.3, balconies_count=2, has_balcony=True, sale_type="Свободная продажа", owners_count=1, encumbrances_clean=False, year_built=2015, views_total=1234, price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}], raw_extra={"wall_type": "монолит"}, ) ok = save_detail_enrichment(db, 999, e) assert ok is True # UPDATE — первый execute update_sql = str(db.execute.call_args_list[0][0][0]) update_params = db.execute.call_args_list[0][0][1] assert "detail_enriched_at = NOW()" in update_sql assert update_params["lid"] == 999 assert update_params["repair_state"] == "good" assert update_params["living_area_m2"] == 45.5 assert update_params["balconies_count"] == 2 assert update_params["has_balcony"] is True assert update_params["encumbrances_clean"] is False # #2674: в колонку уезжает общий с cian/avito токен, а не русская фраза. assert update_params["sale_type"] == "free" assert "монолит" in update_params["raw_extra"] # price-history INSERT с ON CONFLICT (идемпотентность) insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])] assert len(insert_calls) == 1 assert "ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq" in str( insert_calls[0][0][0] ) ins_params = insert_calls[0][0][1] assert ins_params["price"] == 5000000 assert ins_params["ct"] == ct db.commit.assert_called_once() def test_save_detail_enrichment_leaves_recorded_at_to_default() -> None: """Строка загрузчика обязана иметь change_time <> recorded_at. По этому признаку миграция 285 отличает строки загрузчика от строк триггера record_listing_price_change (тот пишет обе метки одним now(), то есть равными) и пересчитывает diff_percent только у первых. У триггера ДРУГАЯ база отсчёта — listings.OLD.price_rub, — поэтому пересчёт его строк ломает честные значения. Признак держится на двух вещах: change_time = дата источника (глубоко в прошлом), а recorded_at загрузчик не указывает вовсе → DEFAULT NOW() на вставке. Пропишут recorded_at в этот INSERT — признак сломается и тест покраснеет. """ db = MagicMock() db.execute.return_value.rowcount = 1 ct = datetime(2026, 4, 1, tzinfo=UTC) e = DomClickDetailEnrichment( item_id="2075729321", source_url=_CARD_URL, price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}], ) assert save_detail_enrichment(db, 999, e) is True insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])] assert len(insert_calls) == 1 insert_sql = str(insert_calls[0][0][0]) assert "recorded_at" not in insert_sql # change_time — историческая дата источника, а не момент вставки. assert insert_calls[0][0][1]["ct"] == ct assert ct < datetime.now(UTC) def test_save_detail_enrichment_listing_not_found() -> None: db = MagicMock() db.execute.return_value.rowcount = 0 e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, repair_state="good") assert save_detail_enrichment(db, 123, e) is False def test_save_detail_enrichment_none_passthrough() -> None: db = MagicMock() db.execute.return_value.rowcount = 1 e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL) # все поля None save_detail_enrichment(db, 1, e) params = db.execute.call_args_list[0][0][1] assert params["repair_state"] is None assert params["living_area_m2"] is None # пустой raw_extra → "{}" (merge no-op, не затирает) assert params["raw_extra"] == "{}" # ── save_detail_enrichment — diff_percent клампится перед INSERT (#2277) ────── # offer_price_history.diff_percent — NUMERIC(8,2) (миграция 131). Прямой INSERT # здесь не проходит через БД-триггер (тот срабатывает только на UPDATE listings) — # garbage/экстремальный diff_percent из скрапленного HTML иначе переполняет колонку. def _insert_diff_param(db: MagicMock) -> float | None: insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])] assert len(insert_calls) == 1 return insert_calls[0][0][1]["diff"] def test_save_detail_enrichment_rejects_rubles_as_diff(caplog: pytest.LogCaptureFixture) -> None: """Величина не того рода (рубли) → NULL + warning, НЕ 999999.99 (#3225).""" db = MagicMock() db.execute.return_value.rowcount = 1 ct = datetime(2026, 4, 1, tzinfo=UTC) e = DomClickDetailEnrichment( item_id="x", source_url=_CARD_URL, price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 250000}], ) with caplog.at_level(logging.WARNING): save_detail_enrichment(db, 777, e) assert _insert_diff_param(db) is None assert any("250000" in r.getMessage() and "777" in r.getMessage() for r in caplog.records) def test_save_detail_enrichment_rejects_extreme_negative_diff() -> None: db = MagicMock() db.execute.return_value.rowcount = 1 ct = datetime(2026, 4, 1, tzinfo=UTC) e = DomClickDetailEnrichment( item_id="x", source_url=_CARD_URL, price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}], ) save_detail_enrichment(db, 1, e) assert _insert_diff_param(db) is None def test_save_detail_enrichment_normal_diff_unchanged() -> None: db = MagicMock() db.execute.return_value.rowcount = 1 ct = datetime(2026, 4, 1, tzinfo=UTC) e = DomClickDetailEnrichment( item_id="x", source_url=_CARD_URL, price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}], ) save_detail_enrichment(db, 1, e) assert _insert_diff_param(db) == -2.5 def test_save_detail_enrichment_none_diff_unchanged() -> None: db = MagicMock() db.execute.return_value.rowcount = 1 ct = datetime(2026, 4, 1, tzinfo=UTC) e = DomClickDetailEnrichment( item_id="x", source_url=_CARD_URL, price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": None}], ) save_detail_enrichment(db, 1, e) assert _insert_diff_param(db) is None # ── validate_diff_percent (scraper_kit.offer_price_history) — standalone unit ─ # Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) — # прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами, # которые его же и используют. def test_validate_diff_percent_rejects_rubles(caplog: pytest.LogCaptureFixture) -> None: with caplog.at_level(logging.WARNING): assert validate_diff_percent(250000, listing_id=406163) is None assert any("250000" in r.getMessage() and "406163" in r.getMessage() for r in caplog.records) def test_validate_diff_percent_rejects_extreme_negative() -> None: assert validate_diff_percent(-5000000) is None def test_validate_diff_percent_boundary_kept() -> None: assert validate_diff_percent(100) == 100.0 assert validate_diff_percent(-100) == -100.0 assert validate_diff_percent(100.01) is None def test_validate_diff_percent_in_range_unchanged() -> None: assert validate_diff_percent(-2.5) == -2.5 assert validate_diff_percent(-0.82) == -0.82 def test_validate_diff_percent_none() -> None: assert validate_diff_percent(None) is None def test_validate_diff_percent_bool_treated_as_none() -> None: assert validate_diff_percent(True) is None assert validate_diff_percent(False) is None # ── canon_sale_type (#2674) ─────────────────────────────────────────────────── # Кейсы — ФАКТИЧЕСКИЙ словарь прода на 2026-08-06: # SELECT source, sale_type, count(*) FROM listings GROUP BY 1,2 → # domklik: «Свободная продажа» 5546 · «Альтернатива» 553 · «Первичная продажа» 2. # Токены-цели — те, что уже пишут cian (free 14800 / alternative 1327) и # avito (free 2976). Третий словарь не заводим. @pytest.mark.parametrize( ("raw", "expected"), [ ("Свободная продажа", "free"), ("Альтернатива", "alternative"), # Регистр/пробелы источника не должны плодить новые значения в колонке. ("свободная продажа", "free"), (" Альтернатива ", "alternative"), # Соответствия в чужих словарях нет: первичка у cian — это ТИП ДОГОВОРА # (fz214/dzhsk/pdkp/dupt), маркетинговая метка его не называет. Не угадываем. ("Первичная продажа", None), # Незнакомое/пустое → None → COALESCE в UPDATE не затрёт существующее. ("Что-то новое", None), ("", None), (None, None), ], ) def test_canon_sale_type_covers_every_prod_value(raw: str | None, expected: str | None) -> None: assert canon_sale_type(raw) == expected def test_canon_sale_type_is_idempotent_on_canon_tokens() -> None: """Токен уже каноничен (повторное обогащение той же строки) → не теряем его.""" assert canon_sale_type("free") is None # чужой токен на вход не подаётся… # …а вот через save_detail_enrichment COALESCE защищает уже записанное: db = MagicMock() db.execute.return_value.rowcount = 1 e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, sale_type="Первичная продажа") save_detail_enrichment(db, 1, e) params = db.execute.call_args_list[0][0][1] assert params["sale_type"] is None assert "COALESCE(:sale_type, sale_type)" in str(db.execute.call_args_list[0][0][0])