gendesign/tradein-mvp/backend/tests/test_cian_serp_offers_total_mismatch.py
bot-backend 0db7a026bd fix(tradein/scrapers): silent-failure guards for MERA scraper audit findings
Общая тема: скрапер тихо возвращал пустоту вместо сигнала об ошибке, продукт
считал оценку по остаткам данных, не зная что источник отвалился.

1. avito serp.py (medium): citywide/byrooms/exhaustive sweep больше не путает
   DOM-drift с "объявлений нет". _is_unexpected_empty_page() перепроверяет
   независимый сигнал (_extract_total_count — счётчик page-title/count либо
   no-results маркер): page=1 c 0 карточками и БЕЗ no-results маркера →
   AvitoContentBlockedError (переиспользует существующий механизм #754/#779).
   Exhaustive-бакеты (_paginate_leaf_bucket) получают expected_total из probe —
   total>0 но 0 карточек после пагинации всех страниц тоже поднимает ошибку.
   page>1 c 0 карточками остаётся graceful end-of-pagination (не regressed).

2. cian serp.py (low): _parse_serp_html сравнивает totalOffers vs
   results.offers из ОДНОГО state-блоба. totalOffers>0 при пустом offers —
   report как schema regression (elevated до ERROR + Glitchtip через новый
   _report_schema_regression(), общий с существующей offer-level охраной).
   Порог 0-vs->0 выбран т.к. это единственный позиционно-независимый сигнал:
   функция не знает номер страницы, дробный порог ложно сработал бы на
   легитимной последней частичной странице exhaustive-пагинации.

3. avito detail.py (low): publish_date больше не завышает свежесть лота на
   границе года. Если "текущий год + месяц/день" даёт дату в будущем
   относительно момента парсинга (декабрьское объявление читается в январе) —
   откатываем на год назад.

4. cian_session.py (low): verify_session больше не конфлирует expired/
   source-down/markup-changed. Новые VERIFY_SOURCE_UNAVAILABLE_SENTINEL (5xx,
   network/timeout exception) и VERIFY_MARKUP_CHANGED_SENTINEL (HTTP 200 но
   header-frontend auth-state не распарсился) отделены от None (единственный
   сигнал реального логаута: 401 или isAuthenticated=false). Callers
   (product_handlers.py::_cian_pre_claim, admin.py) НЕ обновлены — вне
   разрешённого scope этого PR (только cian_session.py + scraper-kit),
   отдельный follow-up.

Тесты: 19 новых (8 avito sweep drift, 6 cian totalOffers mismatch, 5 date
rollover) + обновлены 2 существующих (cian_session markup-changed вместо
None) под новый, более точный контракт. Полный backend suite:
2649 passed, 8 skipped, 1 deselected (без регрессий).
2026-07-26 23:52:39 +03:00

106 lines
5.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.

"""Audit-scrapers finding 2: Cian totalOffers vs results.offers length mismatch.
`_parse_serp_html` извлекает `totalOffers` и `results.offers` из ОДНОГО Redux
state-блоба (одна SSR-выдача). Раньше `results.offers` пустой при `totalOffers>0`
логировался WARNING'ом и тихо возвращался `[]` — не считался schema-regression, не
попадал в мониторинг (`_report_schema_regression`/Glitchtip).
Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
проверить без номера страницы внутри `_parse_serp_html` (эта функция не знает,
какая это страница пагинации — дробный порог типа "< 50% от totalOffers" ложно
сработал бы на легитимной последней частичной странице exhaustive-пагинации,
которую эта функция не различает). totalOffers=0 (реально пустой поиск) НЕ
считается регрессией.
Refs: audit-scrapers 2026-07-26, finding 2 (low).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.cian.serp import CianScraper
from app.services.scraper_adapters import RealScraperConfig
def _scraper() -> CianScraper:
return CianScraper(RealScraperConfig())
def test_total_offers_positive_but_offers_empty_reports_regression() -> None:
"""totalOffers=5, results.offers=[] — internal contradiction, must report."""
s = _scraper()
state = {"results": {"totalOffers": 5, "offers": []}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_called_once()
(msg,), _ = mock_report.call_args
assert "totalOffers=5" in msg
def test_total_offers_zero_and_offers_empty_is_valid_empty_search() -> None:
"""totalOffers=0, offers=[] — легитимная пустая выборка, НЕ регрессия."""
s = _scraper()
state = {"results": {"totalOffers": 0, "offers": []}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_not_called()
def test_total_offers_none_and_offers_empty_is_not_reported_as_regression() -> None:
"""totalOffers отсутствует/None в state — недостаточно сигнала для regression-репорта
(могла быть частично битая state-структура без явного totalOffers>0 контр-сигнала)."""
s = _scraper()
state = {"results": {"offers": []}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_not_called()
def test_offers_present_normal_path_unaffected() -> None:
"""totalOffers=1, offers содержит 1 запись без cianId/id — не проходит
_offer_to_lot, но это уже существующая (0/N offer-level) охрана, не finding 2."""
s = _scraper()
state = {"results": {"totalOffers": 1, "offers": [{"noId": True}]}}
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
# offers_data непустой → finding 2 guard не участвует; существующая offer-level
# охрана (raw_count>0 and saved_count==0) должна отработать вместо неё.
assert lots == []
mock_report.assert_called_once()
(msg,), _ = mock_report.call_args
assert "_offer_to_lot" in msg
def test_state_none_extraction_failed_no_regression_report() -> None:
"""extract_state вернул None (captcha/структура целиком не найдена) — уже
существующая ветка, НЕ должна триггерить finding-2 regression report."""
s = _scraper()
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=None):
with patch.object(s, "_report_schema_regression") as mock_report:
lots = s._parse_serp_html("<html>irrelevant</html>")
assert lots == []
mock_report.assert_not_called()
def test_report_schema_regression_swallows_missing_glitchtip_dsn() -> None:
"""_report_schema_regression не должен падать, если glitchtip_dsn не настроен."""
s = _scraper()
s._config = MagicMock(glitchtip_dsn=None)
s._report_schema_regression("test message") # не должно бросить исключение