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
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 чист на всех изменённых файлах.
217 lines
7.6 KiB
Python
217 lines
7.6 KiB
Python
"""Offline unit tests for CianCitySweepCounters + admin sweep endpoints (#860).
|
||
|
||
Pure & fast: NO live network, NO DB.
|
||
|
||
Legacy `run_cian_city_sweep` orchestration behavior (SERP→save→detail→houses phase
|
||
sequencing, cooperative cancel, consecutive-SERP-failure abort, per-item error
|
||
tolerance) удалён вместе с `app.services.scrape_pipeline` (#2397 Part E1) —
|
||
эквивалентная regression-coverage теперь в
|
||
`test_scraper_kit_pipeline_parity2.py::test_cian_city_sweep` (kit
|
||
`run_cian_city_sweep`).
|
||
|
||
Coverage:
|
||
- CianCitySweepCounters корректно заполняются (dataclass shape)
|
||
- _cian_anchor_timeout_s watchdog-формула (#2160)
|
||
- Admin endpoints (start/cancel/list-runs) — kit sweep вызван с DI (config/matcher)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
|
||
# ── CianCitySweepCounters ───────────────────────────────────────────────────
|
||
|
||
|
||
def test_cian_sweep_counters_defaults() -> None:
|
||
from scraper_kit.orchestration.pipeline import CianCitySweepCounters
|
||
|
||
c = CianCitySweepCounters()
|
||
assert c.anchors_total == 0
|
||
assert c.lots_fetched == 0
|
||
assert c.detail_attempted == 0
|
||
assert c.houses_attempted == 0
|
||
assert c.errors_count == 0
|
||
|
||
|
||
def test_cian_sweep_counters_to_dict_all_keys() -> None:
|
||
from dataclasses import fields
|
||
|
||
from scraper_kit.orchestration.pipeline import CianCitySweepCounters
|
||
|
||
c = CianCitySweepCounters()
|
||
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_cian_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/cian-city-sweep",
|
||
json={"pages_per_anchor": 99},
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_cian_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/cian-city-sweep",
|
||
json={"request_delay_sec": 1.0},
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_cian_start_endpoint_ok(app_with_admin) -> None:
|
||
"""Valid request → 200, run_id; kit sweep вызван с DI (config/matcher, БЕЗ enrichment)."""
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
||
|
||
with (
|
||
patch("app.services.scrape_runs.create_run", return_value=42),
|
||
patch("app.api.v1.admin.run_cian_city_sweep", new_callable=AsyncMock) as mock_sweep,
|
||
):
|
||
r = app_with_admin.post(
|
||
"/api/v1/admin/scrape/cian-city-sweep",
|
||
json={"pages_per_anchor": 2, "detail_top_n": 5, "enrich_houses": False},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["run_id"] == 42
|
||
assert body["status"] == "running"
|
||
assert body["pages_per_anchor"] == 2
|
||
assert body["detail_top_n"] == 5
|
||
|
||
mock_sweep.assert_called_once()
|
||
kw = mock_sweep.call_args.kwargs
|
||
assert isinstance(kw["config"], RealScraperConfig)
|
||
assert isinstance(kw["matcher"], RealMatcherAdapter)
|
||
assert "proxy_provider" in kw
|
||
assert "enrichment" not in kw
|
||
|
||
|
||
def test_cian_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/cian-city-sweep/77/cancel")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["run_id"] == 77
|
||
assert body["cancelled"] is True
|
||
assert body["ok"] is True
|
||
|
||
|
||
def test_cian_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/cian-city-sweep/88/cancel")
|
||
assert r.status_code == 200
|
||
assert r.json()["cancelled"] is False
|
||
|
||
|
||
def test_cian_list_runs_endpoint(app_with_admin) -> None:
|
||
"""List runs endpoint возвращает список."""
|
||
from datetime import UTC, datetime
|
||
from unittest.mock import patch
|
||
|
||
fake_rows = [
|
||
{
|
||
"run_id": 10,
|
||
"source": "cian_city_sweep",
|
||
"status": "done",
|
||
"params": {"pages_per_anchor": 3},
|
||
"counters": {"lots_fetched": 150},
|
||
"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/cian-city-sweep/runs")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert len(body) == 1
|
||
assert body[0]["run_id"] == 10
|
||
assert body[0]["source"] == "cian_city_sweep"
|
||
assert "2025-05-01" in body[0]["started_at"]
|
||
|
||
|
||
# ── #2160: масштабируемый per-anchor watchdog для proxy-pool browser ──────────
|
||
|
||
|
||
def test_cian_anchor_timeout_defaults() -> None:
|
||
"""Формула watchdog даёт worst-case ~1580s при дефолтных параметрах.
|
||
|
||
4 room-buckets × 3 pages × (5s delay + 70s serp-fetch) + 10 detail × 50s + 180s houses
|
||
= 900 + 500 + 180 = 1580s. Фиксированный ANCHOR_TIMEOUT_SEC=240 гильотинил бы якорь в
|
||
SERP-фазе (#2160): каждый SERP-фетч через camoufox 13-45s.
|
||
"""
|
||
from scraper_kit.orchestration.pipeline import ANCHOR_TIMEOUT_SEC, _cian_anchor_timeout_s
|
||
|
||
t = _cian_anchor_timeout_s(
|
||
pages_per_anchor=3,
|
||
request_delay_sec=5.0,
|
||
detail_top_n=10,
|
||
enrich_houses=True,
|
||
)
|
||
assert t == pytest.approx(1580.0)
|
||
assert t > ANCHOR_TIMEOUT_SEC
|
||
|
||
|
||
def test_cian_anchor_timeout_no_houses() -> None:
|
||
"""enrich_houses=False убирает houses-бюджет (180s) из формулы."""
|
||
from scraper_kit.orchestration.pipeline import _cian_anchor_timeout_s
|
||
|
||
t = _cian_anchor_timeout_s(
|
||
pages_per_anchor=3,
|
||
request_delay_sec=5.0,
|
||
detail_top_n=10,
|
||
enrich_houses=False,
|
||
)
|
||
assert t == pytest.approx(1400.0) # 900 + 500 + 0
|
||
|
||
|
||
def test_cian_anchor_timeout_floor_at_anchor_timeout_sec() -> None:
|
||
"""Малые параметры не опускают таймаут ниже ANCHOR_TIMEOUT_SEC (max-ветка)."""
|
||
from scraper_kit.orchestration.pipeline import ANCHOR_TIMEOUT_SEC, _cian_anchor_timeout_s
|
||
|
||
t = _cian_anchor_timeout_s(
|
||
pages_per_anchor=0,
|
||
request_delay_sec=0.0,
|
||
detail_top_n=0,
|
||
enrich_houses=False,
|
||
)
|
||
assert t == float(ANCHOR_TIMEOUT_SEC)
|