All checks were successful
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 1m2s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
app.services.scrapers.{cian,cian_detail,yandex_realty,domclick,domclick_exceptions,
avito_houses} имели 0 runtime-импортёров после E1 (scrape_pipeline.py удалён,
#2405) — admin.py/cian_history_backfill.py уже на scraper_kit.providers.*.
Удаление:
- 6 модулей целиком (avito_houses.py, cian.py, cian_detail.py, domclick.py,
domclick_exceptions.py, yandex_realty.py)
- 3 осиротевших dev-sweep скрипта (local-sweep-ekb{,-cian,-yandex}.py) —
эксклюзивно драйвили удалённые легаси-скраперы, kit-путь их заменяет
Тесты (18 файлов DELETE — эксклюзивно тестировали удалённые модули или были
чистым legacy-vs-kit parity, чей легаси-эталон исчез):
test_cian_detail.py, test_cian_serp_scraper.py, tests/scrapers/test_cian_exhaustive.py,
test_yandex_realty_serp.py, tests/scrapers/test_domclick.py,
test_avito_houses_dedup.py, test_avito_houses_parse.py,
test_houses_rating_filter_entries_1789.py, test_scraper_kit_domclick_golden_parity.py,
tests/scrapers/test_admin_{avito,cian,yandex}_kit_parity.py.
CONVERT (файл покрывает СМЕШАННЫЙ live+legacy набор — вырезаны только
секции удалённых модулей, live-покрытие сохранено):
- test_scraper_kit_cian_golden_parity.py — SERP/`_format_address`/detail
(cian.py/cian_detail.py) убраны; extract_state (cian_state_parser, live)/
valuation (cian_valuation, live)/newbuilding (cian_newbuilding, live)/
no-app-imports guard — остались
- test_scraper_kit_yandex_golden_parity.py — SERP `_entity_to_lot`/
`_parse_gate_json`/`_build_url` (yandex_realty) убраны; normalize_house_type
(live)/newbuilding parse (yandex_newbuilding, live)/no-app-imports guard —
остались
- test_scraper_kit_bisection_parity.py — cian/domclick/yandex-ветки убраны;
avito-ветка (live, единственная оставшаяся легаси-бисекция) — осталась
полностью, включая full-seed-grid
- test_scraper_kit_group_c_backfill_kit_parity.py — cian fetch_detail
browser_fetcher parity-тест убран; avito/house_imv_backfill/
cian_history_backfill/ekb_geoportal тесты — остались
- test_scraper_kit_pricehistory_session_parity.py — fetch_detail
config-injection/proxy-wiring тесты (cian_detail) убраны; extract_state
(cian_state_parser, live)/ScrapedLot (base, live) parity — остались
- test_yandex_scrapers_delay_wiring.py — yandex_realty delay-тест убран
(kit-эквивалент уже покрыт test_kit_serp_proxy_pool.py); yandex_newbuilding
(live)/yandex_detail/yandex_valuation (kit) тесты — остались
- test_scraper_proxy.py — yandex_realty BrowserFetcher-тест убран (redundant
с test_kit_serp_proxy_pool.py::test_yandex_serp_no_pool_by_default)
RETARGET на kit (символ живёт, только источник поменялся):
- test_domclick_sweep.py — `_map_item` → scraper_kit.providers.domclick.serp
- test_yandex_city_sweep.py — DEFAULT_PRICE_RANGES/ROOM_PATH →
scraper_kit.providers.yandex.serp
- test_pipeline_browser_routing.py — fetch_house_catalog →
scraper_kit.providers.avito.houses (+ KitAvitoBlockedError для firewall-теста)
- test_snapshot_writer.py — DetailEnrichment/save_detail_enrichment →
scraper_kit.providers.cian.detail (byte-identical strangler-копия)
- test_backfill_wave2.py — DetailEnrichment → scraper_kit.providers.cian.detail
- tests/scrapers/test_avito_unix_date_tz_consistency.py — avito_houses →
scraper_kit.providers.avito.houses (houses-ветка _unix_to_date/RUS_MONTHS
consolidation-проверки)
Stale-комментарии поправлены в cian_price_history.py, tests/scrapers/
test_domclick_detail.py (описывали ещё-живой domclick.py/domclick_exceptions.py).
grep -rn scrapers\.\(cian\|cian_detail\|yandex_realty\|domclick\|domclick_exceptions\|
avito_houses\) app/ scripts/ packages/ — только historical footnotes
("strangler-копия app.services.scrapers.X", past-tense "удалён/deleted").
Backend suite: 2811 passed, 6 skipped, 1 failed (known flake test_search_cache_hit
#2208, не регрессия). ruff check чист на всех изменённых файлах.
224 lines
8.3 KiB
Python
224 lines
8.3 KiB
Python
"""Offline unit tests for YandexCitySweepCounters + admin sweep endpoints (#861).
|
||
|
||
Pure & fast: NO live network, NO DB.
|
||
|
||
Legacy `run_yandex_city_sweep` orchestration behavior (SERP→save per-combo,
|
||
address-enrich phase, cooperative cancel, consecutive-SERP-failure abort, bounded
|
||
retry / combo-skip logging) удалён вместе с `app.services.scrape_pipeline` (#2397
|
||
Part E1) — эквивалентная regression-coverage теперь в
|
||
`test_scraper_kit_pipeline_parity2.py::test_yandex_city_sweep` (kit
|
||
`run_yandex_city_sweep`).
|
||
|
||
Coverage:
|
||
- YandexCitySweepCounters корректно заполняются (dataclass shape)
|
||
- Combos watchdog timeout существенно больше ANCHOR_TIMEOUT_SEC (arithmetic-only;
|
||
`DEFAULT_PRICE_RANGES`/`ROOM_PATH` — kit `scraper_kit.providers.yandex.serp`,
|
||
ретаргетировано с удалённого `app.services.scrapers.yandex_realty`, #2397 Part E2)
|
||
- Admin endpoints (start/cancel/list-runs) — kit sweep вызван с DI
|
||
(config/matcher/enrichment)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from datetime import UTC
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
|
||
# ── YandexCitySweepCounters ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_counters_defaults() -> None:
|
||
from scraper_kit.orchestration.pipeline import YandexCitySweepCounters
|
||
|
||
c = YandexCitySweepCounters()
|
||
assert c.anchors_total == 0
|
||
assert c.lots_fetched == 0
|
||
assert c.combos_skipped == 0
|
||
assert c.address_attempted == 0
|
||
assert c.address_enriched == 0
|
||
assert c.address_failed == 0
|
||
assert c.errors_count == 0
|
||
|
||
|
||
def test_counters_to_dict_all_keys() -> None:
|
||
from dataclasses import fields
|
||
|
||
from scraper_kit.orchestration.pipeline import YandexCitySweepCounters
|
||
|
||
c = YandexCitySweepCounters()
|
||
d = c.to_dict()
|
||
expected = {f.name for f in fields(c)}
|
||
assert set(d.keys()) == expected
|
||
|
||
|
||
# ── Admin endpoint tests (offline) ──────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture
|
||
def app_with_admin():
|
||
from unittest.mock import MagicMock
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.v1 import admin as admin_module
|
||
from app.core.db import get_db
|
||
|
||
app = FastAPI()
|
||
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||
|
||
def fake_db():
|
||
yield MagicMock()
|
||
|
||
app.dependency_overrides[get_db] = fake_db
|
||
return TestClient(app)
|
||
|
||
|
||
def test_yandex_start_endpoint_validates_pages_per_anchor(app_with_admin) -> None:
|
||
"""pages_per_anchor=99 превышает max=10 → 422."""
|
||
r = app_with_admin.post(
|
||
"/api/v1/admin/scrape/yandex-city-sweep",
|
||
json={"pages_per_anchor": 99},
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_yandex_start_endpoint_validates_delay_too_low(app_with_admin) -> None:
|
||
"""request_delay_sec=1.0 меньше min=3.0 → 422."""
|
||
r = app_with_admin.post(
|
||
"/api/v1/admin/scrape/yandex-city-sweep",
|
||
json={"request_delay_sec": 1.0},
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_yandex_start_endpoint_ok(app_with_admin) -> None:
|
||
"""Valid request → 200, run_id в ответе; kit sweep вызван с DI (config/matcher/enrichment)."""
|
||
from unittest.mock import patch
|
||
|
||
from app.services.scraper_adapters import (
|
||
RealEnrichmentJobs,
|
||
RealMatcherAdapter,
|
||
RealScraperConfig,
|
||
)
|
||
|
||
with (
|
||
patch("app.services.scrape_runs.create_run", return_value=55),
|
||
patch("app.api.v1.admin.run_yandex_city_sweep", new_callable=AsyncMock) as mock_sweep,
|
||
):
|
||
r = app_with_admin.post(
|
||
"/api/v1/admin/scrape/yandex-city-sweep",
|
||
json={"pages_per_anchor": 2, "enrich_address": True},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["run_id"] == 55
|
||
assert body["status"] == "running"
|
||
assert body["pages_per_anchor"] == 2
|
||
assert body["detail_top_n"] == 0
|
||
|
||
mock_sweep.assert_called_once()
|
||
kw = mock_sweep.call_args.kwargs
|
||
assert isinstance(kw["config"], RealScraperConfig)
|
||
assert isinstance(kw["matcher"], RealMatcherAdapter)
|
||
assert isinstance(kw["enrichment"], RealEnrichmentJobs)
|
||
assert "proxy_provider" in kw
|
||
|
||
|
||
def test_yandex_cancel_endpoint(app_with_admin) -> None:
|
||
"""Cancel endpoint возвращает run_id + cancelled=True."""
|
||
from unittest.mock import patch
|
||
|
||
with patch("app.services.scrape_runs.mark_cancelled", return_value=True):
|
||
r = app_with_admin.post("/api/v1/admin/scrape/yandex-city-sweep/99/cancel")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["run_id"] == 99
|
||
assert body["cancelled"] is True
|
||
assert body["ok"] is True
|
||
|
||
|
||
def test_yandex_cancel_endpoint_not_running(app_with_admin) -> None:
|
||
"""Cancel на не-running sweep → cancelled=False."""
|
||
from unittest.mock import patch
|
||
|
||
with patch("app.services.scrape_runs.mark_cancelled", return_value=False):
|
||
r = app_with_admin.post("/api/v1/admin/scrape/yandex-city-sweep/100/cancel")
|
||
assert r.status_code == 200
|
||
assert r.json()["cancelled"] is False
|
||
|
||
|
||
def test_yandex_list_runs_endpoint(app_with_admin) -> None:
|
||
"""List runs endpoint возвращает список."""
|
||
from datetime import datetime
|
||
from unittest.mock import patch
|
||
|
||
fake_rows = [
|
||
{
|
||
"run_id": 20,
|
||
"source": "yandex_city_sweep",
|
||
"status": "done",
|
||
"params": {"pages_per_anchor": 2, "enrich_address": True},
|
||
"counters": {"lots_fetched": 200, "address_enriched": 80},
|
||
"error": None,
|
||
"started_at": datetime(2025, 5, 1, tzinfo=UTC),
|
||
"finished_at": datetime(2025, 5, 1, 1, tzinfo=UTC),
|
||
"heartbeat_at": datetime(2025, 5, 1, 1, tzinfo=UTC),
|
||
}
|
||
]
|
||
with patch("app.services.scrape_runs.list_recent", return_value=fake_rows):
|
||
r = app_with_admin.get("/api/v1/admin/scrape/yandex-city-sweep/runs")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert len(body) == 1
|
||
assert body[0]["run_id"] == 20
|
||
assert body[0]["source"] == "yandex_city_sweep"
|
||
assert "2025-05-01" in body[0]["started_at"]
|
||
|
||
|
||
# ── Watchdog timeout: combos-mode budget dwarfs ANCHOR_TIMEOUT_SEC ──────────
|
||
|
||
|
||
def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None:
|
||
"""В combos/center mode (anchors=None) _sweep_timeout намного > ANCHOR_TIMEOUT_SEC.
|
||
|
||
Проверяет что вычисленный таймаут для дефолтных 30 combos × 3 pages > 1500s,
|
||
т.е. не равен ANCHOR_TIMEOUT_SEC=240 и обеспечивает полный прогон sweep'а.
|
||
Не нужны мокапы — только публичные константы и арифметика из kit-модуля.
|
||
"""
|
||
from scraper_kit.orchestration.pipeline import (
|
||
_YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||
_YANDEX_COMBOS_PER_FETCH_S,
|
||
ANCHOR_TIMEOUT_SEC,
|
||
)
|
||
from scraper_kit.providers.yandex.serp import DEFAULT_PRICE_RANGES, ROOM_PATH
|
||
|
||
# Параметры prod-режима
|
||
rooms_list = list(ROOM_PATH.keys()) # 5 комнатностей
|
||
price_ranges = DEFAULT_PRICE_RANGES # 6 ценовых диапазонов
|
||
max_pages = 3
|
||
request_delay_sec = 9.0 # продакшн default
|
||
|
||
num_combos = len(rooms_list) * len(price_ranges)
|
||
assert num_combos == 30, f"Expected 30 combos, got {num_combos}"
|
||
|
||
sweep_timeout = max(
|
||
ANCHOR_TIMEOUT_SEC,
|
||
num_combos * max_pages * (request_delay_sec + _YANDEX_COMBOS_PER_FETCH_S)
|
||
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
|
||
)
|
||
|
||
# Должен быть существенно > 1500s (≈ 25 мин), что покрывает полный run.
|
||
assert sweep_timeout > 1500, (
|
||
f"sweep_timeout={sweep_timeout:.0f}s unexpectedly small — "
|
||
f"combos-mode will still timeout mid-sweep"
|
||
)
|
||
# И намного > ANCHOR_TIMEOUT_SEC (240s)
|
||
assert (
|
||
sweep_timeout > ANCHOR_TIMEOUT_SEC * 4
|
||
), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s"
|