gendesign/tradein-mvp/backend/tests/test_avito_newbuilding_sweep.py
bot-backend 79a77200d7
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
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 / backend-tests (pull_request) Successful in 1m43s
refactor(tradein/scrapers): удалить legacy scrape_pipeline.py + тест-хирургия (#2397 Part E1)
app.services.scrape_pipeline (3734 строк) имел 0 runtime-импортёров — Part C убрал
последнего вызывающего (admin.py, scheduler_main.py уже на
scraper_kit.orchestration.pipeline). Удаление:

- backend/app/services/scrape_pipeline.py — удалён целиком

Тесты (18 файлов, импортировавших scrape_pipeline):

DELETE (чистая legacy-оркестрация, kit-эквивалент покрыт в
test_scraper_kit_pipeline_parity{,2}.py):
- test_scrape_pipeline.py, test_scrape_pipeline_proxy_pool.py (proxy-pool DB-lease
  вытеснен RealProxyProvider DI-адаптером, покрытым test_scraper_adapters_contracts.py)
- test_anchor_watchdog.py, test_ban_rotation_1790.py, test_provider_rotation_1848.py
- test_scraper_resilience_1949_1950.py, test_sweep_drain.py

CONVERT (parity-сравнение убрано, kit-only regression оставлен/усилен):
- test_scraper_kit_pipeline_parity.py, test_scraper_kit_pipeline_parity2.py —
  _drive_old убран, _drive_new стал единственной веткой; ассёрты на конкретные
  значения counters сохранены (это теперь основное regression-покрытие kit
  orchestration)
- test_sweep_imv_phase.py — 6 IMV-phase тестов (enrich_imv true/false/no-touched/
  crash/failed-counter/cancel) переведены на run_avito_city_sweep (kit) с DI
  (config/matcher/enrichment); enrichment.process_houses_imv_batch — инжектируемый
  Protocol-метод вместо прямого импорта app.services.house_imv_backfill

PARTIAL-EDIT (легаси sweep-behavior вырезан, admin/kit-DI/несвязанные тесты
оставлены):
- test_city_sweep.py — только retarget 4 импортов на scraper_kit.orchestration.pipeline
- test_cian_city_sweep.py, test_yandex_city_sweep.py — sweep-behavior секции удалены,
  admin endpoint тесты (уже kit-DI) + anchor-timeout/watchdog формулы (retarget import)
  оставлены
- test_domclick_sweep.py — sweep-wiring тесты удалены, Counters + _map_item оставлены
- test_avito_newbuilding_sweep.py — sweep-wiring тест #5 удалён, retarget
  NewbuildingSweepCounters
- test_pipeline_browser_routing.py — 3 run_avito_pipeline теста удалены, browser-routing
  на уровне fetch_detail/fetch_house_catalog/AvitoScraper не тронут
- test_scraper_proxy.py, tests/scrapers/test_avito_anti_bot.py — _avito_proxies()
  переведён на kit (DI-параметр вместо settings-patch); pipeline-level тесты удалены

Stale-комментарии (scrape_pipeline.py как текущая реальность) поправлены в
admin.py, avito_detail_backfill.py, house_imv_backfill.py, yandex_price_history.py,
scrapers/avito_detail.py + kit-пакете (orchestration/__init__.py, orchestration
/pipeline.py, providers/avito/detail.py — strangler завершён, легаси удалён).

grep -rn scrape_pipeline app/ scripts/ packages/ — только past-tense упоминания
("удалён в Part E1", "перед его удалением") + один historical footnote
(contracts.py:138, provenance-заметка "собраны grep'ом по ... .py").

Backend suite: 3088 passed, 6 skipped (известный flake test_search_cache_hit #2208 —
не регрессия). ruff check чист на всех изменённых файлах.
2026-07-04 13:56:06 +03:00

161 lines
5.9 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.

"""Avito newbuilding (novostroyka) citywide sweep.
Тесты (без сети, без реального DB):
1. _build_newbuilding_url — novostroyka-slug в пути, s=104, page param, без geo.
2. _parse_html на novostroyka-SERP fixture → listing_segment='novostroyki' +
newbuilding_id заполнен (DOM-маркер застройщика + zhk-якорь).
3. fetch_newbuildings break-on-empty + dedup (mirror fetch_city_wide).
4. NewbuildingSweepCounters — defaults + to_dict.
Legacy `run_avito_newbuilding_sweep` orchestration wiring (fetch_newbuildings +
save_listings + mark_done) удалён вместе с `app.services.scrape_pipeline` (#2397
Part E1) — эквивалентная regression-coverage теперь в
`test_scraper_kit_pipeline_parity2.py::test_avito_newbuilding_sweep` (kit
`run_avito_newbuilding_sweep`).
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.scrapers.avito import NOVOSTROYKA_SLUG, AvitoScraper
from app.services.scrapers.base import ScrapedLot
FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_novostroyka.html"
def _make_lot(source_id: str) -> ScrapedLot:
return ScrapedLot(
source="avito",
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
source_id=source_id,
price_rub=6_000_000,
listing_segment="novostroyki",
)
# ── 1. _build_newbuilding_url ───────────────────────────────────────────────
def test_build_newbuilding_url_contains_slug_and_sort() -> None:
s = AvitoScraper()
url = s._build_newbuilding_url(page=1)
assert NOVOSTROYKA_SLUG in url
assert "novostroyka-" in url
assert "geoCoords" not in url
assert "radius" not in url
assert "s=104" in url
assert "p=1" in url
assert "ekaterinburg/kvartiry/prodam/" in url
def test_build_newbuilding_url_page_param() -> None:
s = AvitoScraper()
url = s._build_newbuilding_url(page=2)
assert "novostroyka-" in url
assert "p=2" in url
def test_build_newbuilding_url_differs_from_citywide() -> None:
s = AvitoScraper()
assert s._build_newbuilding_url(page=1) != s._build_citywide_url(page=1)
# ── 2. _parse_html classifies novostroyka cards ─────────────────────────────
def test_parse_novostroyka_fixture_segments_and_newbuilding_id() -> None:
html = FIXTURE.read_text(encoding="utf-8")
s = AvitoScraper()
lots = s._parse_html(html, source_url_base=s._build_newbuilding_url(page=1))
assert len(lots) == 2
for lot in lots:
assert lot.listing_segment == "novostroyki"
assert lot.newbuilding_id is not None
assert lot.newbuilding_url is not None
ids = {lot.newbuilding_id for lot in lots}
assert "meridian-ekaterinburg" in ids
assert "severnyy-kvartal-ekaterinburg" in ids
# ── 3. fetch_newbuildings break-on-empty + dedup ────────────────────────────
@pytest.mark.asyncio
async def test_fetch_newbuildings_break_on_empty() -> None:
s = AvitoScraper()
pages_data: list[list[ScrapedLot]] = [[_make_lot("A"), _make_lot("B")], []]
call_n = 0
async def mock_html(url: str, page: int) -> str:
assert "novostroyka-" in url
return f"<html>{page}</html>"
def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
nonlocal call_n
idx = call_n
call_n += 1
return pages_data[idx] if idx < len(pages_data) else []
with patch.object(s, "_fetch_serp_html", side_effect=mock_html):
with patch.object(s, "_parse_html", side_effect=mock_parse):
with patch.object(s, "sleep_between_requests", new=AsyncMock()):
result = await s.fetch_newbuildings(pages=10, delay_override_sec=0)
assert len(result) == 2
assert call_n == 2 # page1 + page2(empty) → stop
@pytest.mark.asyncio
async def test_fetch_newbuildings_dedup_by_source_id() -> None:
s = AvitoScraper()
dup = _make_lot("SAME")
pages_data: list[list[ScrapedLot]] = [[dup], [dup], []]
call_n = 0
async def mock_html(url: str, page: int) -> str:
return f"<html>{page}</html>"
def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
nonlocal call_n
idx = call_n
call_n += 1
return pages_data[idx] if idx < len(pages_data) else []
with patch.object(s, "_fetch_serp_html", side_effect=mock_html):
with patch.object(s, "_parse_html", side_effect=mock_parse):
with patch.object(s, "sleep_between_requests", new=AsyncMock()):
result = await s.fetch_newbuildings(pages=10, delay_override_sec=0)
assert len(result) == 1
assert result[0].source_id == "SAME"
def test_fetch_city_wide_url_unchanged() -> None:
"""fetch_city_wide остаётся citywide-URL — рефактор не сменил его поведение."""
s = AvitoScraper()
assert "novostroyka-" not in s._build_citywide_url(page=1)
assert "prodam-ASgBAgICAUSSA8YQ" in s._build_citywide_url(page=1)
# ── 4. NewbuildingSweepCounters ─────────────────────────────────────────────
def test_newbuilding_counters_defaults_and_to_dict() -> None:
from scraper_kit.orchestration.pipeline import NewbuildingSweepCounters
c = NewbuildingSweepCounters()
assert c.lots_fetched == 0
assert c.lots_inserted == 0
assert c.lots_updated == 0
assert c.errors_count == 0
d = c.to_dict()
assert set(d.keys()) == {"lots_fetched", "lots_inserted", "lots_updated", "errors_count"}