gendesign/tradein-mvp/backend/tests/test_sweep_imv_phase.py
lekss361 7a88964ef5
All checks were successful
Deploy Trade-In / test (push) Successful in 36s
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 54s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 45s
fix(avito): scale per-anchor timeout for detail-enrich + durable SERP counters (#1661)
2026-06-17 06:07:50 +00:00

489 lines
18 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.

"""Tests for IMV enrichment phase integrated into Avito city sweep.
Проверяет:
1. enrich_imv=True — sweep вызывает IMV-фазу для touched house_id.
2. enrich_imv=False — IMV не вызывается.
3. IMV counters (imv_attempted, imv_enriched, imv_failed) заполняются.
4. CitySweepCounters содержит новые imv_* поля.
5. process_houses_imv_batch пропускает пустой набор house_ids.
6. CitySweepStartRequest принимает enrich_imv (API-level param).
7. Cooperative cancel перед IMV-фазой — skip IMV, sweep marked done.
NB: run_avito_city_sweep использует внутренние _avito_anchor_phases (не run_avito_pipeline).
Тесты патчат AvitoScraper.fetch_around + save_listings + fetch_house_catalog +
save_house_catalog_enrichment для контроля touched_house_ids.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from dataclasses import fields
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
def _make_house_lot(offer_id: str, house_path: str) -> Any:
"""ScrapedLot с house_url для тестов house-enrich пути."""
from app.services.scrapers.base import ScrapedLot
return ScrapedLot(
source="avito",
source_url=f"https://www.avito.ru{house_path}/{offer_id}",
source_id=offer_id,
address="Тест",
price_rub=3_000_000,
area_m2=40.0,
rooms=1,
house_url=f"https://www.avito.ru{house_path}",
)
def _enter_common_patches(stack: ExitStack, house_ids: list[int]) -> None:
"""Входит в набор patch'ей для базового Avito sweep через ExitStack:
- AvitoScraper.fetch_around → lots с house_url (по одному на house_id)
- save_listings → (n, 0)
- fetch_house_catalog → MagicMock()
- save_house_catalog_enrichment → {"house_id": X} по очереди
- curl_cffi.requests.AsyncSession → fake
- asyncio.sleep → no-op
"""
from app.services.scrapers.avito import AvitoScraper
lots = [_make_house_lot(f"lot-{hid}", f"/ekaterinburg/houses/test/{hid}") for hid in house_ids]
async def fake_fetch_around(self: Any, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
return lots
# save_house_catalog_enrichment вызывается по одному разу на каждый дом
house_id_iter = iter(house_ids)
def fake_save_house(db: Any, enrichment: Any) -> dict:
try:
return {"house_id": next(house_id_iter)}
except StopIteration:
return {}
fake_session = AsyncMock()
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
fake_session.__aexit__ = AsyncMock(return_value=None)
stack.enter_context(patch.object(AvitoScraper, "fetch_around", fake_fetch_around))
stack.enter_context(
patch(
"app.services.scrape_pipeline.save_listings",
lambda _db, _lots, **_kw: (len(_lots), 0),
)
)
stack.enter_context(
patch(
"app.services.scrape_pipeline.fetch_house_catalog",
AsyncMock(return_value=MagicMock()),
)
)
stack.enter_context(
patch(
"app.services.scrape_pipeline.save_house_catalog_enrichment",
fake_save_house,
)
)
stack.enter_context(patch("curl_cffi.requests.AsyncSession", return_value=fake_session))
stack.enter_context(patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()))
# ── CitySweepCounters has imv_* fields ────────────────────────────────────────
def test_city_sweep_counters_has_imv_fields() -> None:
from app.services.scrape_pipeline import CitySweepCounters
c = CitySweepCounters()
assert hasattr(c, "imv_attempted")
assert hasattr(c, "imv_enriched")
assert hasattr(c, "imv_failed")
assert c.imv_attempted == 0
assert c.imv_enriched == 0
assert c.imv_failed == 0
def test_city_sweep_counters_to_dict_has_imv_keys() -> None:
from app.services.scrape_pipeline import CitySweepCounters
c = CitySweepCounters(imv_attempted=5, imv_enriched=3, imv_failed=2)
d = c.to_dict()
assert d["imv_attempted"] == 5
assert d["imv_enriched"] == 3
assert d["imv_failed"] == 2
# Все dataclass-поля должны быть в to_dict
expected_keys = {f.name for f in fields(c)}
assert set(d.keys()) == expected_keys
# ── PipelineResult имеет touched_house_ids ────────────────────────────────────
def test_pipeline_result_has_touched_house_ids() -> None:
from app.services.scrape_pipeline import PipelineCounters, PipelineResult
r = PipelineResult(
anchor_lat=56.84,
anchor_lon=60.6,
radius_m=1500,
counters=PipelineCounters(),
enrich_houses=True,
enrich_detail_top_n=10,
)
assert hasattr(r, "touched_house_ids")
assert isinstance(r.touched_house_ids, set)
assert len(r.touched_house_ids) == 0
def test_pipeline_result_touched_house_ids_set() -> None:
from app.services.scrape_pipeline import PipelineCounters, PipelineResult
r = PipelineResult(
anchor_lat=56.84,
anchor_lon=60.6,
radius_m=1500,
counters=PipelineCounters(),
enrich_houses=True,
enrich_detail_top_n=10,
touched_house_ids={1, 2, 3},
)
assert r.touched_house_ids == {1, 2, 3}
# ── enrich_imv=True → IMV phase вызывается ───────────────────────────────────
@pytest.mark.asyncio
async def test_sweep_enrich_imv_true_calls_imv_batch() -> None:
"""enrich_imv=True + есть touched house_ids → process_houses_imv_batch вызывается.
Патчим AvitoScraper.fetch_around → lots с house_url, save_house_catalog_enrichment
{house_id: X} чтобы all_touched_house_ids = {10, 20}.
"""
from app.services.house_imv_backfill import HouseIMVBackfillResult
from app.services.scrape_pipeline import run_avito_city_sweep
mock_db = MagicMock()
mock_imv_result = HouseIMVBackfillResult(checked=2, saved=2, skipped=0, errors=0)
with ExitStack() as stack:
_enter_common_patches(stack, [10, 20])
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
stack.enter_context(patch("app.services.scrape_runs.mark_done"))
mock_imv_batch = stack.enter_context(
patch(
"app.services.house_imv_backfill.process_houses_imv_batch",
new_callable=AsyncMock,
return_value=mock_imv_result,
)
)
counters = await run_avito_city_sweep(
mock_db,
run_id=1,
anchors=[(56.84, 60.6, "TestAnchor")],
enrich_imv=True,
enrich_houses=True,
detail_top_n=0,
)
mock_imv_batch.assert_awaited_once()
# Первый позиционный аргумент — db, второй — house_ids
call_args = mock_imv_batch.call_args
assert call_args.args[1] == {10, 20}
assert counters.imv_attempted == 2
assert counters.imv_enriched == 2
assert counters.imv_failed == 0
# ── enrich_imv=False → IMV не вызывается ────────────────────────────────────
@pytest.mark.asyncio
async def test_sweep_enrich_imv_false_skips_imv() -> None:
"""enrich_imv=False → process_houses_imv_batch НЕ вызывается."""
from app.services.scrape_pipeline import run_avito_city_sweep
mock_db = MagicMock()
with ExitStack() as stack:
_enter_common_patches(stack, [99])
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
stack.enter_context(patch("app.services.scrape_runs.mark_done"))
mock_imv_batch = stack.enter_context(
patch(
"app.services.house_imv_backfill.process_houses_imv_batch",
new_callable=AsyncMock,
)
)
counters = await run_avito_city_sweep(
mock_db,
run_id=2,
anchors=[(56.84, 60.6, "TestAnchor")],
enrich_imv=False,
enrich_houses=True,
detail_top_n=0,
)
mock_imv_batch.assert_not_awaited()
assert counters.imv_attempted == 0
assert counters.imv_enriched == 0
assert counters.imv_failed == 0
# ── enrich_imv=True но нет touched house_ids → IMV не вызывается ────────────
@pytest.mark.asyncio
async def test_sweep_enrich_imv_no_touched_ids_skips() -> None:
"""enrich_imv=True, но touched_house_ids пустой → IMV не вызывается.
Лоты без house_url → unique_house_paths пуст → touched_house_ids пуст.
"""
from app.services.scrape_pipeline import run_avito_city_sweep
from app.services.scrapers.avito import AvitoScraper
mock_db = MagicMock()
fake_session = AsyncMock()
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
fake_session.__aexit__ = AsyncMock(return_value=None)
# Лоты БЕЗ house_url → нет домов → IMV не вызывается
async def fake_fetch_around(self: Any, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
return []
with (
patch.object(AvitoScraper, "fetch_around", fake_fetch_around),
patch("app.services.scrape_pipeline.save_listings", lambda _db, _lots, **_kw: (0, 0)),
patch("curl_cffi.requests.AsyncSession", return_value=fake_session),
patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
patch("app.services.scrape_runs.is_cancelled", return_value=False),
patch("app.services.scrape_runs.update_heartbeat"),
patch("app.services.scrape_runs.mark_done"),
patch(
"app.services.house_imv_backfill.process_houses_imv_batch",
new_callable=AsyncMock,
) as mock_imv_batch,
):
await run_avito_city_sweep(
mock_db,
run_id=3,
anchors=[(56.84, 60.6, "TestAnchor")],
enrich_imv=True,
enrich_houses=True,
detail_top_n=0,
)
mock_imv_batch.assert_not_awaited()
# ── IMV-ошибки не валят sweep ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sweep_imv_crash_does_not_abort_sweep() -> None:
"""Если process_houses_imv_batch raises — sweep всё равно завершается (mark_done)."""
from app.services.scrape_pipeline import run_avito_city_sweep
mock_db = MagicMock()
with ExitStack() as stack:
_enter_common_patches(stack, [55])
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
mock_mark_done = stack.enter_context(patch("app.services.scrape_runs.mark_done"))
stack.enter_context(
patch(
"app.services.house_imv_backfill.process_houses_imv_batch",
new_callable=AsyncMock,
side_effect=RuntimeError("IMV network error"),
)
)
counters = await run_avito_city_sweep(
mock_db,
run_id=4,
anchors=[(56.84, 60.6, "TestAnchor")],
enrich_imv=True,
enrich_houses=True,
detail_top_n=0,
)
mock_mark_done.assert_called_once()
# IMV crash → ошибка считается, но sweep завершается как done
assert counters.errors_count >= 1
# ── IMV counters накапливают failed ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sweep_imv_failed_counter() -> None:
"""imv_failed отражает errors из HouseIMVBackfillResult."""
from app.services.house_imv_backfill import HouseIMVBackfillResult
from app.services.scrape_pipeline import run_avito_city_sweep
mock_db = MagicMock()
# 3 attempted, 1 enriched, 2 failed
mock_imv_result = HouseIMVBackfillResult(checked=3, saved=1, skipped=0, errors=2)
with ExitStack() as stack:
_enter_common_patches(stack, [1, 2, 3])
stack.enter_context(patch("app.services.scrape_runs.is_cancelled", return_value=False))
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
stack.enter_context(patch("app.services.scrape_runs.mark_done"))
stack.enter_context(
patch(
"app.services.house_imv_backfill.process_houses_imv_batch",
new_callable=AsyncMock,
return_value=mock_imv_result,
)
)
counters = await run_avito_city_sweep(
mock_db,
run_id=5,
anchors=[(56.84, 60.6, "TestAnchor")],
enrich_imv=True,
enrich_houses=True,
detail_top_n=0,
)
assert counters.imv_attempted == 3
assert counters.imv_enriched == 1
assert counters.imv_failed == 2
# errors_count должен включить imv_failed
assert counters.errors_count >= 2
# ── Cancel перед IMV-фазой ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_sweep_cancel_before_imv_skips_imv() -> None:
"""Если is_cancelled=True в IMV-фазе → IMV пропускается, sweep всё равно done."""
from app.services.scrape_pipeline import run_avito_city_sweep
mock_db = MagicMock()
# is_cancelled: первый вызов (anchor loop) = False, второй (IMV phase) = True
cancel_side_effects = [False, True]
call_idx = 0
def is_cancelled_side_effect(db: Any, run_id: int) -> bool:
nonlocal call_idx
val = cancel_side_effects[call_idx] if call_idx < len(cancel_side_effects) else True
call_idx += 1
return val
with ExitStack() as stack:
_enter_common_patches(stack, [77])
stack.enter_context(
patch(
"app.services.scrape_runs.is_cancelled",
side_effect=is_cancelled_side_effect,
)
)
stack.enter_context(patch("app.services.scrape_runs.update_heartbeat"))
mock_mark_done = stack.enter_context(patch("app.services.scrape_runs.mark_done"))
mock_imv_batch = stack.enter_context(
patch(
"app.services.house_imv_backfill.process_houses_imv_batch",
new_callable=AsyncMock,
)
)
await run_avito_city_sweep(
mock_db,
run_id=6,
anchors=[(56.84, 60.6, "TestAnchor")],
enrich_imv=True,
enrich_houses=True,
detail_top_n=0,
)
mock_imv_batch.assert_not_awaited()
mock_mark_done.assert_called_once()
# ── process_houses_imv_batch с пустым set ────────────────────────────────────
@pytest.mark.asyncio
async def test_process_houses_imv_batch_empty_ids() -> None:
"""process_houses_imv_batch с пустым house_ids → result.checked=0, быстрый return."""
from app.services.house_imv_backfill import process_houses_imv_batch
mock_db = MagicMock()
result = await process_houses_imv_batch(mock_db, set(), request_delay_sec=1.0)
assert result.checked == 0
assert result.saved == 0
assert result.errors == 0
# DB не должна вызываться при пустом наборе
mock_db.execute.assert_not_called()
# ── CitySweepStartRequest принимает enrich_imv ───────────────────────────────
def test_city_sweep_start_request_enrich_imv_default_true() -> None:
"""enrich_imv по умолчанию True."""
from app.api.v1.admin import CitySweepStartRequest
req = CitySweepStartRequest()
assert req.enrich_imv is True
def test_city_sweep_start_request_enrich_imv_false() -> None:
"""enrich_imv=False принимается."""
from app.api.v1.admin import CitySweepStartRequest
req = CitySweepStartRequest(enrich_imv=False)
assert req.enrich_imv is False
# ── API endpoint передаёт enrich_imv в sweep ────────────────────────────────
def test_sweep_endpoint_passes_enrich_imv(app_with_admin) -> None:
"""POST /scrape/avito-city-sweep с enrich_imv=False → sweep вызывается без IMV."""
with (
patch("app.api.v1.admin.has_running_run", return_value=False),
patch("app.services.scrape_runs.create_run", return_value=42),
patch(
"app.services.scrape_pipeline.run_avito_city_sweep",
new_callable=AsyncMock,
),
):
r = app_with_admin.post(
"/api/v1/admin/scrape/avito-city-sweep",
json={"pages_per_anchor": 2, "enrich_imv": False},
)
assert r.status_code == 200
body = r.json()
assert body["run_id"] == 42
@pytest.fixture
def app_with_admin():
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)