All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, 44 КБ) и страницу
ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом 200. Детектор сайдкара их не знал
(_REFUSAL_STATUSES {403,429} + маркеры Авито/Домклика), HTML уезжал клиенту как
успех, extract_state возвращал None и провайдер печатал «defaultState extraction
failed» — отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда при этом не
менялась: fetch() уже отрапортовал mark_health(ok=True), fail-streak обнулялся, и
один капча-узел сжигал батч целиком (6200: 0/210; 6123/6091/6052/6032/6010/5981:
0/400 — против 161/162 через здоровый узел на прогоне 13).
Два слоя, потому что образы backend и browser деплоятся раздельно и расходятся
на часы:
* сайдкар (browser/server.py) — детект по <title> на обоих путях (navigate и
подзапрос) → BanPageDetectedError → прежний путь #3288/#3379: 403 + ban_page +
ЧЕСТНЫЙ upstream-статус 200;
* kit (providers/cian/detail.py) — при провале extract_state те же маркеры →
CianBlockedError вместо тихого None, плюс report_platform_ban по живому lease.
Там же ветка SidecarBanPageError: отказ, опознанный сайдкаром, больше не
гасится общим `except` в «не смогли разобрать».
Слово `captcha` признаком быть не может: в нормальной карточке оно встречается 11
раз, на капче 17. Детект по <title> с нормализацией тире.
`_report_platform_ban` → `report_platform_ban` (публичный): тем же путём обязан
идти отказ, распознанный не сайдкаром, а провайдером. report_ban один только
пишет бан пары «узел×источник» — сменить сожжённую аренду ВНУТРИ батча позволяет
только fail-streak (_LEASE_ROTATE_AFTER_FAILS).
1026 lines
50 KiB
Python
1026 lines
50 KiB
Python
"""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 itertools
|
||
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 (
|
||
drop_decimal_slips,
|
||
recompute_diff_percent,
|
||
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 = (
|
||
"<html><head></head><body><script>"
|
||
f"window.__SSR_STATE__ = {_SSR_LITERAL};"
|
||
"</script></body></html>"
|
||
)
|
||
|
||
|
||
def _wrap(state: dict) -> str:
|
||
"""Заворачивает валидный dict в SSR-HTML (для variant-фикстур без undefined)."""
|
||
literal = json.dumps(state, ensure_ascii=False)
|
||
return f"<html><body><script>window.__SSR_STATE__ = {literal};</script></body></html>"
|
||
|
||
|
||
# ── _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("<html><body>no ssr state here</body></html>")
|
||
|
||
|
||
def test_extract_ssr_state_challenge_raises_blocked() -> None:
|
||
html = "<html><body>Access denied by DataDome captcha</body></html>"
|
||
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",
|
||
[
|
||
"<html><body>BOT_MITIGATION в процессе, подождите</body></html>",
|
||
"<html><body>СИСТЕМА ЗАЩИТЫ от ботов активна</body></html>",
|
||
"<html><body>403 | ДОМКЛИК — доступ ограничен</body></html>",
|
||
],
|
||
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="<html><body>no state</body></html>")
|
||
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="<html><body>Access denied datadome</body></html>")
|
||
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="<html><body>Access denied datadome</body></html>")
|
||
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
|
||
|
||
|
||
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
|
||
# Прод-замер 06.09.2026 под ЭТИМ критерием: 38 точек (cian 12, domklik 26,
|
||
# yandex 0). Вид: 377 000 → 3 770 000 → 3 720 000 — цена возвращается к базе
|
||
# следующей же точкой.
|
||
|
||
|
||
def test_drop_decimal_slips_drops_spike_up(caplog: pytest.LogCaptureFixture) -> None:
|
||
"""Точка ×10 с возвратом к базе выброшена; соседи по бокам целы."""
|
||
series = [19064000, 190150000, 19150000]
|
||
with caplog.at_level(logging.WARNING):
|
||
kept, dropped = drop_decimal_slips(series, lambda p: p, listing_id=406163)
|
||
assert kept == [19064000, 19150000]
|
||
assert dropped == 1
|
||
assert any("406163" in r.getMessage() and "19064000" in r.getMessage() for r in caplog.records)
|
||
|
||
|
||
def test_drop_decimal_slips_drops_spike_down() -> None:
|
||
"""Тот же критерий в другую сторону: ÷10 и возврат к базе (377 000 при базе 3.7 млн)."""
|
||
kept, dropped = drop_decimal_slips([3720000, 377000, 3770000], lambda p: p)
|
||
assert kept == [3720000, 3770000]
|
||
assert dropped == 1
|
||
|
||
|
||
def test_drop_decimal_slips_keeps_honest_doubling() -> None:
|
||
"""Рост ×2 — не сдвиг разряда, серия не тронута."""
|
||
series = [5000000, 10000000, 9900000]
|
||
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
|
||
|
||
|
||
def test_drop_decimal_slips_keeps_tenfold_without_return_to_base() -> None:
|
||
"""×10 БЕЗ возврата к базе — возможное честное изменение цены, точку не трогаем.
|
||
|
||
Хвост 1 020 000 → 10 200 000 → 10 150 000 намеренно стоит НЕ первым: ровно та
|
||
же тройка в начале серии — это уже сдвиг по правилу первой точки (см.
|
||
test_drop_decimal_slips_drops_first_point_witnessed_by_third), и правила здесь
|
||
не спорят, а смотрят на разные концы серии. Внутри серии у 10 200 000 есть
|
||
база слева, и она НЕ подтверждена следующей точкой (9.95 ≠ 1) — точка честная.
|
||
"""
|
||
series = [1000000, 1020000, 10200000, 10150000]
|
||
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
|
||
|
||
|
||
def test_drop_decimal_slips_last_point_checked_against_current_price() -> None:
|
||
"""У последней точки следующей нет — свидетель это текущая цена объявления."""
|
||
kept, dropped = drop_decimal_slips([1020000, 10200000], lambda p: p, current_price=1020000)
|
||
assert kept == [1020000]
|
||
assert dropped == 1
|
||
|
||
|
||
def test_drop_decimal_slips_last_point_kept_without_current_price() -> None:
|
||
"""Второго свидетеля нет вообще → точка остаётся (могла быть честной сменой цены)."""
|
||
series = [1020000, 10200000]
|
||
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
|
||
|
||
|
||
# ── правило ПЕРВОЙ точки (#3376, прод-разбор 06.09.2026) ─────────────────────
|
||
# Серии, у которых дефектная точка первая: 420 000 → 4 200 000 → 4 500 000. Слева
|
||
# базы нет, основное правило такую точку не видит. Свидетелей по-прежнему двое, и
|
||
# оба — из самой истории: вторая точка и ТРЕТЬЯ. Кандидатов первой точки на проде
|
||
# было 21, свидетеля из истории имеют 4 (все domklik).
|
||
|
||
|
||
def test_drop_decimal_slips_drops_first_point_witnessed_by_third() -> None:
|
||
"""420 000 → 4 200 000 → 4 500 000: вторую точку подтверждает третья."""
|
||
kept, dropped = drop_decimal_slips([420000, 4200000, 4500000], lambda p: p)
|
||
assert (kept, dropped) == ([4200000, 4500000], 1)
|
||
|
||
|
||
def test_drop_decimal_slips_keeps_first_point_when_series_runs_away() -> None:
|
||
"""Первая ×10 ко второй, но третья ушла ЕЩЁ дальше — это разгон цены, не разряд."""
|
||
series = [400000, 4000000, 8000000]
|
||
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
|
||
|
||
|
||
def test_drop_decimal_slips_keeps_first_point_without_third_point() -> None:
|
||
"""330 000 → 3 300 000: третьей точки нет — свидетеля нет, ДАЖЕ с ценой лота.
|
||
|
||
Цена объявления в правиле первой точки не участвует сознательно: у 17 из 21
|
||
прод-кандидата (yandex 6, domklik 9, cian 2) listings.price_rub в точности
|
||
равнялась второй точке — у yandex это буквально одна переменная lot.price_rub,
|
||
записанная в двух местах. Такой «второй свидетель» — то же наблюдение, а
|
||
удаление точки необратимо. Верни COALESCE на current_price в правило первой
|
||
точки — первый assert покраснеет.
|
||
"""
|
||
series = [330000, 3300000]
|
||
assert drop_decimal_slips(series, lambda p: p, current_price=3300000) == (series, 0)
|
||
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
|
||
|
||
|
||
def test_drop_decimal_slips_first_point_decided_on_kept_series() -> None:
|
||
"""Свидетели первой точки — ОСТАВШИЕСЯ соседи, а не сырые.
|
||
|
||
1M → 10M → 100M → 10M: 100M выбрасывает основное правило, и только после этого
|
||
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
|
||
сырым соседям — первый проход оставил бы 1M, а второй выбросил, то есть гейт
|
||
перестал бы быть идемпотентным (перебор ниже ловит 1116 таких прогонов, и эта
|
||
серия — первый из них).
|
||
"""
|
||
kept, dropped = drop_decimal_slips(
|
||
[1_000_000, 10_000_000, 100_000_000, 10_000_000], lambda p: p
|
||
)
|
||
assert (kept, dropped) == ([10_000_000, 10_000_000], 2)
|
||
|
||
|
||
# ── база = предыдущая ОСТАВЛЕННАЯ точка (фальсификаторы ревьюера) ────────────
|
||
|
||
|
||
def test_drop_decimal_slips_alternating_series_keeps_honest_points() -> None:
|
||
"""1M → 10M → 1M → 10M при цене 1M: обе честные точки на месте.
|
||
|
||
Первая редакция миграции 286 брала базой СЫРУЮ предыдущую строку (lag) и на
|
||
этой серии оставляла [1M] вместо [1M, 1M] — удаляла честную точку.
|
||
"""
|
||
kept, dropped = drop_decimal_slips(
|
||
[1_000_000, 10_000_000, 1_000_000, 10_000_000], lambda p: p, current_price=1_000_000
|
||
)
|
||
assert (kept, dropped) == ([1_000_000, 1_000_000], 2)
|
||
|
||
|
||
def test_drop_decimal_slips_base_is_previous_kept_not_raw() -> None:
|
||
"""1M → 10M → 1.05M → 9.9M при цене 1M: после выброса 10M база для 1.05M — это 1M.
|
||
|
||
По сырой базе 1.05M тоже стала бы кандидатом (10M/1.05M = 9.52), а следующий
|
||
прогон доел бы 9.9M — ровно та неидемпотентность, которую нашёл ревьюер.
|
||
"""
|
||
kept, dropped = drop_decimal_slips(
|
||
[1_000_000, 10_000_000, 1_050_000, 9_900_000], lambda p: p, current_price=1_000_000
|
||
)
|
||
assert (kept, dropped) == ([1_000_000, 1_050_000, 9_900_000], 1)
|
||
|
||
|
||
# ── свойство: gate(gate(s)) == gate(s) ───────────────────────────────────────
|
||
|
||
_SLIP_ALPHABET = (1_000_000, 10_000_000, 100_000_000, 1_050_000, 9_900_000)
|
||
_SLIP_CURRENTS = (None, 1_000_000, 10_000_000)
|
||
|
||
|
||
def test_drop_decimal_slips_is_idempotent(caplog: pytest.LogCaptureFixture) -> None:
|
||
"""Повторный прогон гейта не трогает ничего — на всех сериях длины 2-6.
|
||
|
||
Свойство несущее, а не декоративное: миграция 286 зеркалит эту функцию
|
||
пошагово, а доказать идемпотентность на самом SQL-файле нечем — прод-прогон
|
||
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
|
||
|
||
ФАЛЬСИФИЦИРУЕМОСТЬ (перемерено 06.09.2026 на этом же переборе — 19 525 серий
|
||
× 3 цены = 58 575 прогонов):
|
||
• база = сырая предыдущая точка вместо оставленной → 136 неидемпотентных
|
||
прогонов, первый же — (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] → [1M];
|
||
• решение по первой точке на сырых соседях вместо оставшихся → 1116 прогонов,
|
||
первый — (1M, 10M, 100M, 10M) без текущей цены: [1M, 10M, 10M] → [10M, 10M].
|
||
"""
|
||
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
|
||
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")
|
||
for length in range(2, 7):
|
||
for series in itertools.product(_SLIP_ALPHABET, repeat=length):
|
||
for current in _SLIP_CURRENTS:
|
||
once, _ = drop_decimal_slips(list(series), lambda p: p, current_price=current)
|
||
twice, dropped_again = drop_decimal_slips(once, lambda p: p, current_price=current)
|
||
assert (twice, dropped_again) == (once, 0), (
|
||
f"серия {series} при текущей цене {current}: {once} → {twice}"
|
||
)
|
||
|
||
|
||
# ── recompute_diff_percent — общий пересчёт после выброса ────────────────────
|
||
|
||
|
||
def test_recompute_diff_percent_from_surviving_neighbours() -> None:
|
||
"""Процент считается от оставшейся базы; у первой точки базы нет → NULL."""
|
||
changes: list[dict[str, object]] = [
|
||
{"price_rub": 3720000, "diff_percent": -89.87},
|
||
{"price_rub": 3770000, "diff_percent": 900.0},
|
||
]
|
||
recompute_diff_percent(changes)
|
||
assert [c["diff_percent"] for c in changes] == [None, 1.34]
|
||
|
||
|
||
def test_recompute_diff_percent_survives_none_price() -> None:
|
||
"""price_rub=None не роняет пересчёт: ручной ingest кладёт price_changes сырьём.
|
||
|
||
scripts/ingest_domclick_jsonl.py:108 берёт rec["price_changes"] из JSONL без
|
||
валидации — арифметика по None здесь дала бы TypeError на всю запись.
|
||
"""
|
||
changes: list[dict[str, object]] = [
|
||
{"price_rub": 5_000_000, "diff_percent": 12.0},
|
||
{"price_rub": None, "diff_percent": 900.0},
|
||
{"price_rub": 5_100_000, "diff_percent": -90.0},
|
||
]
|
||
recompute_diff_percent(changes)
|
||
assert [c["diff_percent"] for c in changes] == [None, None, 2.0]
|
||
|
||
|
||
def test_save_detail_enrichment_drops_decimal_slip_before_insert() -> None:
|
||
"""Писатель не отправляет спайк в INSERT, а соседу пересчитывает diff_percent."""
|
||
db = MagicMock()
|
||
db.execute.return_value.rowcount = 1
|
||
# SELECT price_rub FROM listings — текущая цена объявления (свидетель).
|
||
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=3770000)
|
||
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2, 3)]
|
||
e = DomClickDetailEnrichment(
|
||
item_id="2075729321",
|
||
source_url=_CARD_URL,
|
||
price_changes=[
|
||
{"change_time": times[0], "price_rub": 3720000, "diff_percent": None},
|
||
# Сдвиг разряда: ÷10 к предыдущей, следующая возвращается к базе.
|
||
{"change_time": times[1], "price_rub": 377000, "diff_percent": -89.87},
|
||
{"change_time": times[2], "price_rub": 3770000, "diff_percent": 900.0},
|
||
],
|
||
)
|
||
|
||
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 [c[0][1]["price"] for c in insert_calls] == [3720000, 3770000]
|
||
# У точки-соседа diff_percent парсер считал от исчезнувшей базы 377 000 (+900%,
|
||
# такое validate_diff_percent отвергает). От настоящей базы 3 720 000 это +1.34%.
|
||
assert insert_calls[1][0][1]["diff"] == 1.34
|
||
|
||
|
||
def test_save_detail_enrichment_witness_comes_only_from_listing_price() -> None:
|
||
"""Серия из ДВУХ точек: второго свидетеля даёт исключительно цена листинга.
|
||
|
||
Здесь чтение цены не декорация, а единственный вход гейта: у последней точки
|
||
следующей нет. Сломай его (RETURNING убран / fetchone → None) — свидетеля не
|
||
станет, спайк 10 200 000 уедет в offer_price_history, и тест покраснеет на
|
||
втором INSERT'е. Проверено подменой fetchone → None.
|
||
"""
|
||
db = MagicMock()
|
||
db.execute.return_value.rowcount = 1
|
||
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=1020000)
|
||
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2)]
|
||
e = DomClickDetailEnrichment(
|
||
item_id="x",
|
||
source_url=_CARD_URL,
|
||
price_changes=[
|
||
{"change_time": times[0], "price_rub": 1020000, "diff_percent": None},
|
||
{"change_time": times[1], "price_rub": 10200000, "diff_percent": 900.0},
|
||
],
|
||
)
|
||
|
||
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 [c[0][1]["price"] for c in insert_calls] == [1020000]
|
||
# Цену отдаёт тот же UPDATE — отдельного SELECT по PK больше нет.
|
||
assert "RETURNING price_rub" in str(db.execute.call_args_list[0][0][0])
|
||
assert not [c for c in db.execute.call_args_list if "SELECT price_rub" in str(c[0][0])]
|
||
|
||
|
||
# ── 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])
|