"""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 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 app.services.scrape_pipeline import CitySweepCounters from dataclasses import fields 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 # ── 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 в ответе.""" with ( patch("app.services.scrape_runs.create_run", return_value=7) as mock_create, 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, "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 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, timezone 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=timezone.utc), "finished_at": datetime(2025, 1, 1, 1, tzinfo=timezone.utc), "heartbeat_at": datetime(2025, 1, 1, 1, tzinfo=timezone.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"]