gendesign/tradein-mvp/backend/tests/test_city_sweep.py
bot-backend d0b011d388
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 1m37s
test(tradein/admin): перенацелить patch-таргеты на kit + DI-guard asserts (#2397 slice A)
После миграции admin.py на scraper_kit.orchestration.pipeline (предыдущий
коммит) 4 endpoint-теста продолжали патчить устаревший
app.services.scrape_pipeline.run_* — имя, на которое admin.py больше не
ссылается. Мок не перехватывал вызов → FastAPI BackgroundTask реально
запускал kit-sweep (сетевой I/O), что локально (без :5432) тихо падало
на подключении к БД и глоталось except'ом, а на CI грозило зависанием/таймаутом.

Перенацелены patch-таргеты на app.api.v1.admin.run_* (namespace, куда
забинжен kit-импорт):
- test_city_sweep.py::test_start_endpoint_ok -> run_avito_city_sweep
- test_cian_city_sweep.py::test_cian_start_endpoint_ok -> run_cian_city_sweep
- test_yandex_city_sweep.py::test_yandex_start_endpoint_ok -> run_yandex_city_sweep
- test_sweep_imv_phase.py::test_sweep_endpoint_passes_enrich_imv -> run_avito_city_sweep

Добавлены DI-guard assertions (закрывают coverage-gap: раньше проверялся
только HTTP 200, не факт вызова kit с правильным DI) — mock_sweep.call_args.kwargs
проверяется на RealScraperConfig/RealMatcherAdapter/RealEnrichmentJobs (там, где
kit-сигнатура их требует) + наличие proxy_provider.
2026-07-04 12:14:14 +03:00

389 lines
14 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.

"""Offline smoke tests for city sweep utilities + endpoints.
Тесты НЕ требуют реального DB — мокируем SessionLocal и scrape_runs модуль.
"""
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, MagicMock, patch
import pytest
# ── EKB_ANCHORS ────────────────────────────────────────────────────────────
def test_ekb_anchors_count() -> None:
from app.services.scrape_pipeline import EKB_ANCHORS
assert len(EKB_ANCHORS) == 5
for lat, lon, name in EKB_ANCHORS:
assert 56.5 < lat < 57.0, f"lat={lat} out of EKB range for anchor {name}"
assert 60.3 < lon < 60.8, f"lon={lon} out of EKB range for anchor {name}"
assert isinstance(name, str) and name
# ── CitySweepCounters ───────────────────────────────────────────────────────
def test_city_sweep_counters_defaults() -> None:
from app.services.scrape_pipeline import CitySweepCounters
c = CitySweepCounters()
assert c.anchors_total == 0
assert c.lots_fetched == 0
assert c.errors_count == 0
def test_city_sweep_counters_to_dict() -> None:
from app.services.scrape_pipeline import CitySweepCounters
c = CitySweepCounters(anchors_total=5, anchors_done=2, lots_fetched=150)
d = c.to_dict()
assert d["anchors_total"] == 5
assert d["anchors_done"] == 2
assert d["lots_fetched"] == 150
assert "errors_count" in d
assert "houses_enriched" in d
assert "detail_enriched" in d
def test_city_sweep_counters_to_dict_all_keys() -> None:
from dataclasses import fields
from app.services.scrape_pipeline import CitySweepCounters
c = CitySweepCounters()
d = c.to_dict()
expected_keys = {f.name for f in fields(c)}
assert set(d.keys()) == expected_keys
# ── scrape_runs utilities (unit, no DB) ────────────────────────────────────
def test_scrape_runs_create_run_returns_id() -> None:
from app.services.scrape_runs import create_run
mock_db = MagicMock()
mock_row = MagicMock()
mock_row.id = 42
mock_db.execute.return_value.fetchone.return_value = mock_row
run_id = create_run(mock_db, source="avito_city_sweep", params={"pages_per_anchor": 3})
assert run_id == 42
mock_db.commit.assert_called()
def test_scrape_runs_is_cancelled_true() -> None:
from app.services.scrape_runs import is_cancelled
mock_db = MagicMock()
mock_row = MagicMock()
mock_row.status = "cancelled"
mock_db.execute.return_value.fetchone.return_value = mock_row
assert is_cancelled(mock_db, 99) is True
def test_scrape_runs_is_cancelled_false_when_running() -> None:
from app.services.scrape_runs import is_cancelled
mock_db = MagicMock()
mock_row = MagicMock()
mock_row.status = "running"
mock_db.execute.return_value.fetchone.return_value = mock_row
assert is_cancelled(mock_db, 99) is False
def test_scrape_runs_is_cancelled_false_when_not_found() -> None:
from app.services.scrape_runs import is_cancelled
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = None
assert is_cancelled(mock_db, 999) is False
def test_scrape_runs_mark_cancelled_returns_bool() -> None:
from app.services.scrape_runs import mark_cancelled
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = MagicMock() # row found
result = mark_cancelled(mock_db, 10)
assert result is True
mock_db.commit.assert_called()
def test_scrape_runs_mark_cancelled_returns_false_when_not_running() -> None:
from app.services.scrape_runs import mark_cancelled
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = None # no row (not running)
result = mark_cancelled(mock_db, 10)
assert result is False
# ── total_seen / new_count column population (audit #1926) ──────────────────
def _captured_params(mock_db: MagicMock) -> dict:
"""Извлечь dict bind-параметров из последнего db.execute(text(...), params)."""
assert mock_db.execute.call_args is not None, "db.execute was not called"
return mock_db.execute.call_args.args[1]
def test_column_counts_maps_lots_fetched_inserted() -> None:
"""_column_counts: lots_fetched→total_seen, lots_inserted→new_count."""
from app.services.scrape_runs import _column_counts
total_seen, new_count = _column_counts(
{"lots_fetched": 150, "lots_inserted": 12, "anchors_done": 5}
)
assert total_seen == 150
assert new_count == 12
def test_column_counts_prefers_explicit_total_seen_new_count() -> None:
"""Если в counters уже есть total_seen/new_count — они приоритетнее lots_*."""
from app.services.scrape_runs import _column_counts
total_seen, new_count = _column_counts(
{"total_seen": 99, "new_count": 7, "lots_fetched": 1, "lots_inserted": 1}
)
assert total_seen == 99
assert new_count == 7
def test_column_counts_returns_none_when_absent() -> None:
"""Нет ни одного из ключей → None (COALESCE сохранит старое значение колонки)."""
from app.services.scrape_runs import _column_counts
total_seen, new_count = _column_counts({"anchors_done": 3})
assert total_seen is None
assert new_count is None
def test_mark_done_persists_total_seen_new_count_columns() -> None:
"""mark_done пишет total_seen/new_count из lots_fetched/lots_inserted (audit #1926)."""
from app.services.scrape_runs import mark_done
mock_db = MagicMock()
mock_db.execute.return_value.first.return_value = MagicMock(id=1) # row updated
mark_done(mock_db, 1, {"lots_fetched": 200, "lots_inserted": 18, "anchors_done": 5})
params = _captured_params(mock_db)
assert params["total_seen"] == 200
assert params["new_count"] == 18
# SQL must SET the dedicated columns, not only the jsonb blob
sql = str(mock_db.execute.call_args.args[0])
assert "total_seen" in sql
assert "new_count" in sql
mock_db.commit.assert_called()
def test_update_heartbeat_persists_total_seen_new_count_columns() -> None:
"""update_heartbeat также обновляет колонки (live progress, не только финал)."""
from app.services.scrape_runs import update_heartbeat
mock_db = MagicMock()
update_heartbeat(mock_db, 1, {"lots_fetched": 75, "lots_inserted": 4})
params = _captured_params(mock_db)
assert params["total_seen"] == 75
assert params["new_count"] == 4
mock_db.commit.assert_called()
def test_mark_done_yandex_counters_populate_columns() -> None:
"""End-to-end shape: YandexCitySweepCounters.to_dict() → mark_done → columns set."""
from app.services.scrape_pipeline import YandexCitySweepCounters
from app.services.scrape_runs import mark_done
counters = YandexCitySweepCounters(
anchors_total=1, anchors_done=1, lots_fetched=540, lots_inserted=42
)
mock_db = MagicMock()
mock_db.execute.return_value.first.return_value = MagicMock(id=1)
mark_done(mock_db, 1, counters.to_dict())
params = _captured_params(mock_db)
assert params["total_seen"] == 540
assert params["new_count"] == 42
def test_mark_done_avito_cian_counters_populate_columns() -> None:
"""End-to-end shape: CitySweepCounters.to_dict() (avito/cian) → mark_done → columns set.
CitySweepCounters обслуживает avito + cian city-sweep'ы и проходит тот же путь
counters.to_dict() → mark_done, что и Yandex — фикс #1926 должен заполнять
total_seen/new_count и для них (issue: «то же касается CitySweepCounters»).
"""
from app.services.scrape_pipeline import CitySweepCounters
from app.services.scrape_runs import mark_done
counters = CitySweepCounters(
anchors_total=4, anchors_done=4, lots_fetched=1234, lots_inserted=56
)
mock_db = MagicMock()
mock_db.execute.return_value.first.return_value = MagicMock(id=1)
mark_done(mock_db, 1, counters.to_dict())
params = _captured_params(mock_db)
assert params["total_seen"] == 1234
assert params["new_count"] == 56
def test_mark_failed_persists_total_seen_new_count_columns() -> None:
"""Даже при mark_failed колонки заполняются из частично собранных counters (#1926).
Sweep может упасть на середине, успев собрать тысячи лотов — observability должна
видеть total_seen>0, а не 0 для failed-прогона.
"""
from app.services.scrape_runs import mark_failed
mock_db = MagicMock()
mock_db.execute.return_value.first.return_value = MagicMock(id=1)
mark_failed(mock_db, 1, "boom", {"lots_fetched": 300, "lots_inserted": 9})
# mark_failed после UPDATE дёргает _alert_on_run_id (SELECT source ...), поэтому
# последний execute — это alert-SELECT, а не наш UPDATE. Ищем UPDATE-вызов явно.
update_call = next(
c for c in mock_db.execute.call_args_list if "UPDATE scrape_runs" in str(c.args[0])
)
params = update_call.args[1]
assert params["total_seen"] == 300
assert params["new_count"] == 9
sql = str(update_call.args[0])
assert "total_seen" in sql
assert "new_count" in sql
# ── FastAPI endpoints (offline) ─────────────────────────────────────────────
@pytest.fixture
def app_with_admin():
from fastapi import FastAPI
from fastapi.testclient import TestClient
# Lazy import to avoid side effects before env is set
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_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/avito-city-sweep",
json={"pages_per_anchor": 99},
)
assert r.status_code == 422
def test_start_endpoint_validates_request_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/avito-city-sweep",
json={"request_delay_sec": 1.0},
)
assert r.status_code == 422
def test_start_endpoint_ok(app_with_admin) -> None:
"""Valid request → 200, run_id в ответе; kit sweep вызван с DI (config/matcher/enrichment)."""
from app.services.scraper_adapters import (
RealEnrichmentJobs,
RealMatcherAdapter,
RealScraperConfig,
)
with (
patch("app.api.v1.admin.has_running_run", return_value=False),
patch("app.services.scrape_runs.create_run", return_value=7),
patch("app.api.v1.admin.run_avito_city_sweep", new_callable=AsyncMock) as mock_sweep,
):
r = app_with_admin.post(
"/api/v1/admin/scrape/avito-city-sweep",
json={"pages_per_anchor": 2, "detail_top_n": 10},
)
assert r.status_code == 200
body = r.json()
assert body["run_id"] == 7
assert body["status"] == "running"
assert body["pages_per_anchor"] == 2
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_cancel_endpoint_exists(app_with_admin) -> None:
"""Cancel endpoint returns run_id + cancelled flag."""
with patch("app.services.scrape_runs.mark_cancelled", return_value=True):
r = app_with_admin.post("/api/v1/admin/scrape/avito-city-sweep/123/cancel")
assert r.status_code == 200
body = r.json()
assert body["run_id"] == 123
assert body["cancelled"] is True
assert body["ok"] is True
def test_cancel_endpoint_not_running(app_with_admin) -> None:
"""Cancel на уже завершённый run → cancelled=False."""
with patch("app.services.scrape_runs.mark_cancelled", return_value=False):
r = app_with_admin.post("/api/v1/admin/scrape/avito-city-sweep/456/cancel")
assert r.status_code == 200
assert r.json()["cancelled"] is False
def test_list_runs_endpoint(app_with_admin) -> None:
"""List runs endpoint возвращает список."""
from datetime import datetime
fake_rows = [
{
"run_id": 1,
"source": "avito_city_sweep",
"status": "done",
"params": {"pages_per_anchor": 3},
"counters": {"lots_fetched": 300},
"error": None,
"started_at": datetime(2025, 1, 1, tzinfo=UTC),
"finished_at": datetime(2025, 1, 1, 1, tzinfo=UTC),
"heartbeat_at": datetime(2025, 1, 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/avito-city-sweep/runs")
assert r.status_code == 200
body = r.json()
assert len(body) == 1
assert body[0]["run_id"] == 1
assert body[0]["status"] == "done"
assert "2025-01-01" in body[0]["started_at"]