"""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("irrelevant") 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("irrelevant") 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("irrelevant") 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("irrelevant") # 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("irrelevant") 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") # не должно бросить исключение